3231 lines
102 KiB
TypeScript
3231 lines
102 KiB
TypeScript
/**
|
|
* Custom agent CLI for structured/headless profiles (#147).
|
|
*
|
|
* This is an alternative human view for an agent cell, not a replacement for
|
|
* the native TUI. It uses the structured chat commands when available and
|
|
* deliberately does not try to parse PTY bytes.
|
|
*/
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useId,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ClipboardEvent,
|
|
type KeyboardEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type RefObject,
|
|
} from "react";
|
|
|
|
import type {
|
|
AgentProfile,
|
|
GatewayError,
|
|
ReplyChunk,
|
|
ReplyProgress,
|
|
ReplyProgressKind,
|
|
ReplyProgressSource,
|
|
ReplyProgressStage,
|
|
SlashCommand,
|
|
} from "@/domain";
|
|
import { useGateways } from "@/app/di";
|
|
import {
|
|
AnnouncementsPreview,
|
|
shortRestartFailure,
|
|
useAgentRestart,
|
|
} from "@/features/announcements";
|
|
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
|
|
import {
|
|
markProjectCliInteractionFinished,
|
|
markProjectCliInteractionStarted,
|
|
markProjectCliInteractionStopped,
|
|
} from "@/features/projects/projectCliInteractionActivity";
|
|
import { Button, Spinner, cn } from "@/shared";
|
|
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
|
|
|
|
export interface CustomAgentChatViewProps {
|
|
projectId: string;
|
|
agentId: string;
|
|
agentName: string;
|
|
profile: AgentProfile;
|
|
cwd: string;
|
|
layoutId?: string;
|
|
nodeId?: string;
|
|
sessionId: string | null;
|
|
conversationId: string | null;
|
|
composerHeightPx?: number;
|
|
composerDefaultHeightPx?: number;
|
|
onComposerHeightPxChange?: (heightPx: number) => void;
|
|
draftScope?: ChatDraftScope | null;
|
|
mobile?: boolean;
|
|
onOpenNativeTui?: () => void;
|
|
onSessionId: (sessionId: string | null) => void;
|
|
onConversationId: (conversationId: string | null) => void;
|
|
}
|
|
|
|
type ChatTurn =
|
|
| { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number }
|
|
| { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number }
|
|
| { role: "tool"; label: string; receivedAtMs?: number }
|
|
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
|
|
| { role: "final"; text: string; receivedAtMs?: number }
|
|
| { role: "error"; text: string; receivedAtMs?: number }
|
|
| { role: "restart"; key: string; text: string; message?: string; failed?: boolean }
|
|
| { role: "unknown"; text: string; receivedAtMs?: number };
|
|
|
|
interface CachedChatView {
|
|
turns: ChatTurn[];
|
|
progressIndex: Map<string, ProgressIndexEntry>;
|
|
progressScope: number;
|
|
chunkCount: number;
|
|
sessionId?: string;
|
|
}
|
|
|
|
const chatSessionViewCache = new Map<string, CachedChatView>();
|
|
const chatConversationViewCache = new Map<string, CachedChatView>();
|
|
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
|
const COMPOSER_RESIZE_STEP = 12;
|
|
const FALLBACK_COMPOSER_HEIGHT = 64;
|
|
const COMPOSER_HISTORY_LIMIT = 100;
|
|
const MIN_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
|
|
const MAX_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2100, 0, 1);
|
|
const MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor(
|
|
MIN_REASONABLE_CHAT_TIMESTAMP_MS / 1000,
|
|
);
|
|
const MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor(
|
|
MAX_REASONABLE_CHAT_TIMESTAMP_MS / 1000,
|
|
);
|
|
|
|
export function __clearCustomAgentChatViewCacheForTests() {
|
|
chatSessionViewCache.clear();
|
|
chatConversationViewCache.clear();
|
|
}
|
|
|
|
interface AttachmentDraft {
|
|
id: string;
|
|
label: string;
|
|
input: ChatAttachmentInput;
|
|
previewUrl?: string;
|
|
dedupeKey?: string;
|
|
token?: string;
|
|
}
|
|
|
|
interface ProgressIndexEntry {
|
|
firstOccurredAtMs: number;
|
|
sequence: number;
|
|
}
|
|
|
|
interface ProfileCommandDialogState {
|
|
profiles: AgentProfile[];
|
|
selectedProfileId: string;
|
|
confirmedReset: boolean;
|
|
busy: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
interface SlashCommandInvocation {
|
|
name: string;
|
|
arguments: unknown[];
|
|
}
|
|
|
|
interface ChatErrorState {
|
|
message: string;
|
|
openFailure: boolean;
|
|
}
|
|
|
|
interface RequesterDelegationState {
|
|
currentAgentBusy: boolean;
|
|
delegationTargetName: string | null;
|
|
}
|
|
|
|
function describe(e: unknown): string {
|
|
if (e && typeof e === "object" && "message" in e) {
|
|
return String((e as GatewayError).message);
|
|
}
|
|
return String(e);
|
|
}
|
|
|
|
function describeOpenFailure(e: unknown): string {
|
|
const message = describe(e).trim();
|
|
return message || "Cause technique non disponible.";
|
|
}
|
|
|
|
function workAgentDisplayName(agent: { agentId: string; name: string }): string {
|
|
return agent.name.trim() || `Agent ${agent.agentId.slice(0, 8)}`;
|
|
}
|
|
|
|
function structuredSessionLabel(
|
|
adapter: AgentProfile["structuredAdapter"],
|
|
): string | null {
|
|
switch (adapter) {
|
|
case "codex":
|
|
return "Session Codex";
|
|
case "claude":
|
|
return "Session Claude";
|
|
case "openCode":
|
|
return "Session OpenCode";
|
|
case "openAiCompatible":
|
|
return "Session HTTP";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function instrumentationError(e: unknown): Record<string, unknown> {
|
|
if (e && typeof e === "object") {
|
|
const err = e as GatewayError & { name?: string };
|
|
return {
|
|
code: err.code,
|
|
message: err.message,
|
|
name: err.name,
|
|
raw: e,
|
|
};
|
|
}
|
|
return { message: String(e), raw: e };
|
|
}
|
|
|
|
function isNotFound(e: unknown): boolean {
|
|
if (e && typeof e === "object" && (e as GatewayError).code === "NOT_FOUND") {
|
|
return true;
|
|
}
|
|
const message =
|
|
typeof e === "string"
|
|
? e
|
|
: e && typeof e === "object" && "message" in e
|
|
? String((e as GatewayError).message)
|
|
: "";
|
|
return /^not found: structured session\b/i.test(message);
|
|
}
|
|
|
|
function unknownChunkLabel(chunk: unknown): string {
|
|
try {
|
|
return JSON.stringify(chunk);
|
|
} catch {
|
|
return String(chunk);
|
|
}
|
|
}
|
|
|
|
function isReplyRecord(chunk: unknown): chunk is Record<string, unknown> {
|
|
return Boolean(chunk && typeof chunk === "object" && "kind" in chunk);
|
|
}
|
|
|
|
function appendAgentDelta(
|
|
turns: ChatTurn[],
|
|
text: string,
|
|
receivedAtMs: number,
|
|
): ChatTurn[] {
|
|
const next = [...turns];
|
|
const last = next[next.length - 1];
|
|
if (last?.role === "agent") {
|
|
next[next.length - 1] = {
|
|
role: "agent",
|
|
text: last.text + text,
|
|
pending: true,
|
|
receivedAtMs: last.receivedAtMs ?? receivedAtMs,
|
|
};
|
|
return next;
|
|
}
|
|
next.push({ role: "agent", text, pending: true, receivedAtMs });
|
|
return next;
|
|
}
|
|
|
|
function attachmentLabels(value: unknown): string[] {
|
|
return Array.isArray(value)
|
|
? value.filter((item): item is string => typeof item === "string" && item.trim() !== "")
|
|
: [];
|
|
}
|
|
|
|
function appendUserPrompt(
|
|
turns: ChatTurn[],
|
|
text: string,
|
|
attachments: string[] = [],
|
|
receivedAtMs = Date.now(),
|
|
): { turns: ChatTurn[]; appended: boolean } {
|
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
const turn = turns[index];
|
|
if (turn.role === "final" || turn.role === "error") break;
|
|
if (turn.role !== "user") continue;
|
|
if (
|
|
turn.text === text &&
|
|
JSON.stringify(turn.attachments ?? []) === JSON.stringify(attachments)
|
|
) {
|
|
return { turns, appended: false };
|
|
}
|
|
break;
|
|
}
|
|
return {
|
|
turns: [
|
|
...turns,
|
|
{
|
|
role: "user",
|
|
text,
|
|
receivedAtMs,
|
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
},
|
|
],
|
|
appended: true,
|
|
};
|
|
}
|
|
|
|
function isReplyProgressSource(value: unknown): value is ReplyProgressSource {
|
|
return value === "providerNative" || value === "ideaLocal";
|
|
}
|
|
|
|
function isReplyProgressKind(value: unknown): value is ReplyProgressKind {
|
|
return (
|
|
value === "turn" ||
|
|
value === "message" ||
|
|
value === "tool" ||
|
|
value === "mcp" ||
|
|
value === "other"
|
|
);
|
|
}
|
|
|
|
function isReplyProgressStage(value: unknown): value is ReplyProgressStage {
|
|
return (
|
|
value === "started" ||
|
|
value === "delta" ||
|
|
value === "completed" ||
|
|
value === "info"
|
|
);
|
|
}
|
|
|
|
function optionalString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
}
|
|
|
|
function normalizeProgress(raw: unknown): ReplyProgress | null {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const record = raw as Record<string, unknown>;
|
|
const label = optionalString(record.label);
|
|
if (!label) return null;
|
|
return {
|
|
progressId: optionalString(record.progressId),
|
|
occurredAtMs:
|
|
typeof record.occurredAtMs === "number" && Number.isFinite(record.occurredAtMs)
|
|
? record.occurredAtMs
|
|
: undefined,
|
|
source: isReplyProgressSource(record.source) ? record.source : "providerNative",
|
|
kind: isReplyProgressKind(record.kind) ? record.kind : "other",
|
|
stage: isReplyProgressStage(record.stage) ? record.stage : "info",
|
|
label,
|
|
text: optionalString(record.text),
|
|
provider: optionalString(record.provider),
|
|
nativeEvent: optionalString(record.nativeEvent),
|
|
toolName: optionalString(record.toolName),
|
|
};
|
|
}
|
|
|
|
function progressKey(progress: ReplyProgress): string {
|
|
return [
|
|
progress.source,
|
|
progress.kind,
|
|
progress.stage,
|
|
progress.toolName ?? "",
|
|
progress.label,
|
|
progress.text ?? "",
|
|
].join("\u0000");
|
|
}
|
|
|
|
function progressUpsertKey(progress: ReplyProgress, scope: number): string {
|
|
const progressId = progress.progressId?.trim();
|
|
if (progressId) return `progress:${scope}:${progressId}`;
|
|
return `legacy:${scope}:${progressDisplayKey(progress)}`;
|
|
}
|
|
|
|
function turnProgressKey(turn: Extract<ChatTurn, { role: "progress" }>): string {
|
|
return turn.progressKey ?? progressUpsertKey(turn.progress, 0);
|
|
}
|
|
|
|
function normalizedProgressToolName(progress: ReplyProgress): string | null {
|
|
const name = progress.toolName ?? progress.label;
|
|
const normalized = name.trim().toLowerCase();
|
|
return normalized || null;
|
|
}
|
|
|
|
function isDelegationProgress(progress: ReplyProgress): boolean {
|
|
const name = normalizedProgressToolName(progress);
|
|
return name === "idea_ask_agent" || name === "idea_ask_agents";
|
|
}
|
|
|
|
function progressEntityKey(progress: ReplyProgress): string | null {
|
|
const name = normalizedProgressToolName(progress);
|
|
if (!name) return null;
|
|
if (isDelegationProgress(progress)) return `delegation:${name}`;
|
|
if (progress.kind === "tool" || progress.kind === "mcp") return `tool:${name}`;
|
|
return null;
|
|
}
|
|
|
|
function progressStageBucket(progress: ReplyProgress): string {
|
|
if (!isCenteredToolProgress(progress)) return progress.stage;
|
|
if (progress.stage === "started" || progress.stage === "delta") return "started";
|
|
if (progress.stage === "completed") return "completed";
|
|
return "info";
|
|
}
|
|
|
|
function progressDisplayKey(progress: ReplyProgress): string {
|
|
const entity = progressEntityKey(progress);
|
|
if (entity) return `${entity}\u0000${progressStageBucket(progress)}`;
|
|
return progressKey(progress);
|
|
}
|
|
|
|
function legacyActiveProgressKey(progress: ReplyProgress, scope: number): string | null {
|
|
const entity = progressEntityKey(progress);
|
|
if (!entity) return null;
|
|
if (progress.stage !== "started" && progress.stage !== "delta") return null;
|
|
return `legacy:${scope}:${entity}\u0000started`;
|
|
}
|
|
|
|
function legacyCompletedDedupKey(progress: ReplyProgress): string | null {
|
|
const entity = progressEntityKey(progress);
|
|
if (!entity || progress.stage !== "completed") return null;
|
|
if (isDelegationProgress(progress)) return entity;
|
|
return `${entity}\u0000${progress.text ?? ""}`;
|
|
}
|
|
|
|
function findLastProgressIndex(
|
|
turns: ChatTurn[],
|
|
predicate: (turn: Extract<ChatTurn, { role: "progress" }>) => boolean,
|
|
): number {
|
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
const turn = turns[index];
|
|
if (turn.role === "progress" && predicate(turn)) return index;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function legacyCompletedOccurrenceKey(
|
|
progress: ReplyProgress,
|
|
entry: ProgressIndexEntry,
|
|
scope: number,
|
|
): string {
|
|
return `legacy-completed:${scope}:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`;
|
|
}
|
|
|
|
interface ChatViewState {
|
|
turns: ChatTurn[];
|
|
progressIndex: Map<string, ProgressIndexEntry>;
|
|
progressScope: number;
|
|
}
|
|
|
|
function emptyChatViewState(): ChatViewState {
|
|
return { turns: [], progressIndex: new Map(), progressScope: 0 };
|
|
}
|
|
|
|
function conversationCacheKey(
|
|
projectId: string,
|
|
agentId: string,
|
|
conversationId: string | null,
|
|
): string | null {
|
|
return conversationId ? `${projectId}::${agentId}::${conversationId}` : null;
|
|
}
|
|
|
|
function composerHistoryStorageKey(
|
|
projectId: string,
|
|
agentId: string,
|
|
layoutId: string | undefined,
|
|
nodeId: string | undefined,
|
|
): string {
|
|
return [
|
|
"idea.custom-chat-history",
|
|
projectId,
|
|
layoutId ?? "default",
|
|
nodeId ?? "agent",
|
|
agentId,
|
|
].join(".");
|
|
}
|
|
|
|
function readComposerHistory(storageKey: string): string[] {
|
|
if (typeof window === "undefined") return [];
|
|
try {
|
|
const parsed: unknown = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]");
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
.slice(-COMPOSER_HISTORY_LIMIT);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writeComposerHistory(storageKey: string, history: string[]): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(
|
|
storageKey,
|
|
JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)),
|
|
);
|
|
} catch {
|
|
/* local history only */
|
|
}
|
|
}
|
|
|
|
function appendComposerHistoryEntry(history: string[], text: string): string[] {
|
|
const entry = text.trim();
|
|
if (!entry) return history;
|
|
const withoutDuplicateTail = history.at(-1) === entry ? history.slice(0, -1) : history;
|
|
return [...withoutDuplicateTail, entry].slice(-COMPOSER_HISTORY_LIMIT);
|
|
}
|
|
|
|
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
|
let key = progressUpsertKey(progress, state.progressScope);
|
|
let existingIndex = state.turns.findIndex(
|
|
(turn) => turn.role === "progress" && turnProgressKey(turn) === key,
|
|
);
|
|
|
|
const activeKey =
|
|
!progress.progressId && progress.stage === "completed"
|
|
? legacyActiveProgressKey({ ...progress, stage: "started" }, state.progressScope)
|
|
: null;
|
|
if (activeKey) {
|
|
const activeIndex = state.turns.findIndex(
|
|
(turn) => turn.role === "progress" && turnProgressKey(turn) === activeKey,
|
|
);
|
|
if (activeIndex >= 0) {
|
|
existingIndex = activeIndex;
|
|
key = activeKey;
|
|
}
|
|
}
|
|
|
|
const completedDedupKey = existingIndex < 0 ? legacyCompletedDedupKey(progress) : null;
|
|
if (completedDedupKey) {
|
|
existingIndex = findLastProgressIndex(
|
|
state.turns,
|
|
(turn) =>
|
|
turnProgressKey(turn).startsWith(`legacy-completed:${state.progressScope}:`) &&
|
|
legacyCompletedDedupKey(turn.progress) === completedDedupKey,
|
|
);
|
|
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
|
|
if (existingTurn?.role === "progress") {
|
|
key = turnProgressKey(existingTurn);
|
|
}
|
|
}
|
|
|
|
const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null;
|
|
const existingProgress =
|
|
existingTurn?.role === "progress"
|
|
? existingTurn.progress
|
|
: null;
|
|
const merged =
|
|
existingProgress && progress.stage === "completed"
|
|
? mergeCompletedProgress(existingProgress, progress)
|
|
: progress;
|
|
const nextIndex = new Map(state.progressIndex);
|
|
const existingEntry = nextIndex.get(key);
|
|
const firstOccurredAtMs =
|
|
existingEntry?.firstOccurredAtMs ??
|
|
progress.occurredAtMs ??
|
|
Date.now();
|
|
const sequence = existingEntry?.sequence ?? nextIndex.size;
|
|
const nextEntry = { firstOccurredAtMs, sequence };
|
|
let nextKey = key;
|
|
if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) {
|
|
nextKey = legacyCompletedOccurrenceKey(merged, nextEntry, state.progressScope);
|
|
nextIndex.delete(activeKey);
|
|
}
|
|
nextIndex.set(nextKey, nextEntry);
|
|
|
|
if (existingIndex >= 0) {
|
|
const nextTurns = [...state.turns];
|
|
nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey };
|
|
return { ...state, turns: nextTurns, progressIndex: nextIndex };
|
|
}
|
|
|
|
const insertAt = state.turns.findIndex((turn) => {
|
|
if (turn.role !== "progress") return false;
|
|
const candidate = state.progressIndex.get(turnProgressKey(turn));
|
|
if (!candidate) return false;
|
|
if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
|
|
return candidate.firstOccurredAtMs > firstOccurredAtMs;
|
|
}
|
|
return candidate.sequence > sequence;
|
|
});
|
|
const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey };
|
|
if (insertAt < 0) {
|
|
return { ...state, turns: [...state.turns, nextTurn], progressIndex: nextIndex };
|
|
}
|
|
return {
|
|
...state,
|
|
turns: [
|
|
...state.turns.slice(0, insertAt),
|
|
nextTurn,
|
|
...state.turns.slice(insertAt),
|
|
],
|
|
progressIndex: nextIndex,
|
|
};
|
|
}
|
|
|
|
function mergeCompletedProgress(
|
|
running: ReplyProgress,
|
|
completed: ReplyProgress,
|
|
): ReplyProgress {
|
|
if (!isDelegationProgress(running) || !completed.text) return completed;
|
|
if (delegationSummary(completed).target) return completed;
|
|
return {
|
|
...completed,
|
|
text: running.text,
|
|
};
|
|
}
|
|
|
|
function progressShouldSettle(
|
|
turn: Extract<ChatTurn, { role: "progress" }>,
|
|
completedEntities: Set<string | null>,
|
|
): boolean {
|
|
if (
|
|
turn.progress.kind === "turn" ||
|
|
(turn.progress.stage !== "started" && turn.progress.stage !== "delta")
|
|
) {
|
|
return false;
|
|
}
|
|
const entity = progressEntityKey(turn.progress);
|
|
return Boolean(entity && completedEntities.has(entity));
|
|
}
|
|
|
|
function shouldCompleteProgress(
|
|
turn: Extract<ChatTurn, { role: "progress" }>,
|
|
): boolean {
|
|
return (
|
|
turn.progress.kind !== "turn" &&
|
|
(turn.progress.stage === "started" || turn.progress.stage === "delta")
|
|
);
|
|
}
|
|
|
|
function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
|
const completedEntities = new Set(
|
|
state.turns
|
|
.filter(
|
|
(turn): turn is Extract<ChatTurn, { role: "progress" }> =>
|
|
turn.role === "progress" &&
|
|
progressStageBucket(turn.progress) === "completed" &&
|
|
Boolean(progressEntityKey(turn.progress)),
|
|
)
|
|
.map((turn) => progressEntityKey(turn.progress)),
|
|
);
|
|
|
|
const nextIndex = new Map(state.progressIndex);
|
|
const turns = state.turns.map((turn) => {
|
|
if (turn.role !== "progress" || !shouldCompleteProgress(turn)) return turn;
|
|
|
|
const previousKey = turnProgressKey(turn);
|
|
const previousEntry = nextIndex.get(previousKey);
|
|
const entry =
|
|
previousEntry ?? {
|
|
firstOccurredAtMs: turn.progress.occurredAtMs ?? Date.now(),
|
|
sequence: nextIndex.size,
|
|
};
|
|
const completedProgress = {
|
|
...turn.progress,
|
|
stage: "completed" as const,
|
|
};
|
|
const nextKey = turn.progress.progressId
|
|
? progressUpsertKey(completedProgress, state.progressScope)
|
|
: legacyCompletedOccurrenceKey(completedProgress, entry, state.progressScope);
|
|
if (previousKey !== nextKey) nextIndex.delete(previousKey);
|
|
nextIndex.set(nextKey, entry);
|
|
|
|
if (progressShouldSettle(turn, completedEntities)) {
|
|
return {
|
|
...turn,
|
|
settled: true,
|
|
progress: completedProgress,
|
|
progressKey: nextKey,
|
|
};
|
|
}
|
|
return {
|
|
...turn,
|
|
progress: completedProgress,
|
|
progressKey: nextKey,
|
|
};
|
|
});
|
|
|
|
return { ...state, turns, progressIndex: nextIndex };
|
|
}
|
|
|
|
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
|
return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE;
|
|
}
|
|
|
|
function turnsIndicateBusy(state: ChatViewState): boolean {
|
|
let sawOpenActivity = false;
|
|
for (let index = state.turns.length - 1; index >= 0; index -= 1) {
|
|
const turn = state.turns[index];
|
|
if (turn.role === "final" || turn.role === "error") return false;
|
|
if (turn.role === "user") return sawOpenActivity;
|
|
if (turn.role === "agent" && turn.pending) sawOpenActivity = true;
|
|
if (
|
|
turn.role === "progress" &&
|
|
!turn.settled &&
|
|
progressStageBucket(turn.progress) !== "completed"
|
|
) {
|
|
sawOpenActivity = true;
|
|
}
|
|
}
|
|
return sawOpenActivity;
|
|
}
|
|
|
|
function insertTextAtSelection(
|
|
text: string,
|
|
insertion: string,
|
|
selectionStart: number,
|
|
selectionEnd: number,
|
|
): string {
|
|
return text.slice(0, selectionStart) + insertion + text.slice(selectionEnd);
|
|
}
|
|
|
|
function removeAttachmentToken(text: string, token: string): string {
|
|
const index = text.indexOf(token);
|
|
if (index < 0) return text;
|
|
const before = text.slice(0, index);
|
|
const after = text.slice(index + token.length);
|
|
if (/\s$/.test(before) && /^\s/.test(after)) {
|
|
return before + after.replace(/^\s+/, "");
|
|
}
|
|
return before + after;
|
|
}
|
|
|
|
function fileExtension(mime: string): string {
|
|
if (mime === "image/png") return "png";
|
|
if (mime === "image/jpeg") return "jpg";
|
|
if (mime === "image/gif") return "gif";
|
|
if (mime === "image/webp") return "webp";
|
|
return "img";
|
|
}
|
|
|
|
function pathBasename(path: string): string {
|
|
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
|
|
}
|
|
|
|
function bytesToBase64(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
const chunkSize = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
const chunk = bytes.subarray(i, i + chunkSize);
|
|
binary += String.fromCharCode(...chunk);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
|
|
const mime = file.type || "application/octet-stream";
|
|
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
|
const filename =
|
|
file.name ||
|
|
(mime.startsWith("image/")
|
|
? `clipboard-image-${Date.now()}.${fileExtension(mime)}`
|
|
: `clipboard-file-${Date.now()}`);
|
|
return {
|
|
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
label: filename,
|
|
previewUrl: mime.startsWith("image/")
|
|
? `data:${mime};base64,${contentBase64}`
|
|
: undefined,
|
|
dedupeKey: file.name
|
|
? fileMetadataKey(file)
|
|
: `${mime}\u0000${file.size}\u0000${contentBase64}`,
|
|
input: {
|
|
filename,
|
|
contentBase64,
|
|
mime,
|
|
sourceKind: "clipboard",
|
|
},
|
|
};
|
|
}
|
|
|
|
function fileMetadataKey(file: File): string {
|
|
return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
|
}
|
|
|
|
function dataTransferFiles(clipboardData: DataTransfer): File[] {
|
|
const clipboardItems = clipboardData.items
|
|
? Array.from(clipboardData.items)
|
|
: [];
|
|
const clipboardFiles = clipboardData.files
|
|
? Array.from(clipboardData.files)
|
|
: [];
|
|
const fromItems = clipboardItems
|
|
.filter((item) => item.kind === "file")
|
|
.map((item) => item.getAsFile())
|
|
.filter((file): file is File => Boolean(file));
|
|
const seen = new Set<string>();
|
|
return [...fromItems, ...clipboardFiles].filter((file) => {
|
|
const key = fileMetadataKey(file);
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function asyncClipboardImageFiles(): Promise<File[]> {
|
|
if (
|
|
typeof navigator === "undefined" ||
|
|
typeof navigator.clipboard?.read !== "function"
|
|
) {
|
|
return [];
|
|
}
|
|
const items = await navigator.clipboard.read();
|
|
const files: File[] = [];
|
|
for (const item of items) {
|
|
for (const type of item.types) {
|
|
if (!type.startsWith("image/")) continue;
|
|
const blob = await item.getType(type);
|
|
files.push(new File([blob], "", { type, lastModified: 0 }));
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function slashCommandPrefix(draft: string): string | null {
|
|
if (!draft.startsWith("/")) return null;
|
|
if (/\s/.test(draft)) return null;
|
|
return draft;
|
|
}
|
|
|
|
function slashCommandInvocation(text: string): SlashCommandInvocation | null {
|
|
if (!text.startsWith("/")) return null;
|
|
const [name, ...argumentParts] = text.split(/\s+/);
|
|
if (!name) return null;
|
|
const rawArguments = argumentParts.join(" ").trim();
|
|
return {
|
|
name,
|
|
arguments: rawArguments ? [rawArguments] : [],
|
|
};
|
|
}
|
|
|
|
function isSlashCommandAvailable(command: SlashCommand): boolean {
|
|
return command.availability.status === "available";
|
|
}
|
|
|
|
function formatSlashCommandHelp(commands: SlashCommand[]): string {
|
|
return commands
|
|
.map((command) => `${command.name} - ${command.shortDescription}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function lastAgentReceivedAtMs(turns: ChatTurn[]): number | undefined {
|
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
const turn = turns[index];
|
|
if (turn.role === "agent") return turn.receivedAtMs;
|
|
if (turn.role === "user" || turn.role === "final" || turn.role === "error") {
|
|
return undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function foldChunk(
|
|
state: ChatViewState,
|
|
raw: unknown,
|
|
receivedAtMs = Date.now(),
|
|
): ChatViewState {
|
|
const { turns } = state;
|
|
if (!isReplyRecord(raw)) {
|
|
return {
|
|
...state,
|
|
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
|
|
};
|
|
}
|
|
const last = turns[turns.length - 1];
|
|
if (
|
|
(last?.role === "final" || last?.role === "error") &&
|
|
raw.kind !== "userPrompt" &&
|
|
raw.kind !== "UserPrompt"
|
|
) {
|
|
if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") {
|
|
return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw, receivedAtMs);
|
|
}
|
|
return state;
|
|
}
|
|
switch (raw.kind) {
|
|
case "textDelta":
|
|
return {
|
|
...state,
|
|
turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs),
|
|
};
|
|
case "toolActivity":
|
|
return {
|
|
...state,
|
|
turns: [
|
|
...turns,
|
|
{ role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs },
|
|
],
|
|
};
|
|
case "progress": {
|
|
const progress = normalizeProgress(raw.progress);
|
|
if (!progress) {
|
|
return {
|
|
...state,
|
|
turns: [
|
|
...turns,
|
|
{ role: "unknown", text: unknownChunkLabel(raw), receivedAtMs },
|
|
],
|
|
};
|
|
}
|
|
return appendProgress(state, progress);
|
|
}
|
|
case "final": {
|
|
const content = String(raw.content ?? "");
|
|
const completedState = completeChatViewProgress(state);
|
|
const next = [...completedState.turns];
|
|
const last = next[next.length - 1];
|
|
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
|
next.push({
|
|
role: "final",
|
|
text: content,
|
|
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
|
|
});
|
|
return { ...completedState, turns: next };
|
|
}
|
|
case "error": {
|
|
const completedState = completeChatViewProgress(state);
|
|
const next = [...completedState.turns];
|
|
const last = next[next.length - 1];
|
|
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
|
next.push({
|
|
role: "error",
|
|
text: String(raw.message ?? "Erreur agent"),
|
|
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
|
|
});
|
|
return { ...completedState, turns: next };
|
|
}
|
|
case "userPrompt":
|
|
case "UserPrompt": {
|
|
const appended = appendUserPrompt(
|
|
turns,
|
|
String(raw.text ?? raw.prompt ?? ""),
|
|
attachmentLabels(raw.attachments),
|
|
receivedAtMs,
|
|
);
|
|
return {
|
|
...state,
|
|
turns: appended.turns,
|
|
progressScope: appended.appended
|
|
? state.progressScope + 1
|
|
: state.progressScope,
|
|
};
|
|
}
|
|
default:
|
|
return {
|
|
...state,
|
|
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
|
|
};
|
|
}
|
|
}
|
|
|
|
export function CustomAgentChatView({
|
|
projectId,
|
|
agentId,
|
|
agentName,
|
|
profile,
|
|
cwd,
|
|
layoutId,
|
|
nodeId,
|
|
sessionId,
|
|
conversationId,
|
|
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
|
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
|
onComposerHeightPxChange,
|
|
draftScope: explicitDraftScope,
|
|
mobile = false,
|
|
onOpenNativeTui,
|
|
onSessionId,
|
|
onConversationId,
|
|
}: CustomAgentChatViewProps) {
|
|
const { agent, profile: profileGateway, system, workState } = useGateways();
|
|
const pluginRuntime = usePluginRuntime();
|
|
const initialConversationCacheKey = conversationCacheKey(
|
|
projectId,
|
|
agentId,
|
|
conversationId,
|
|
);
|
|
const [turns, setTurns] = useState<ChatTurn[]>(
|
|
() =>
|
|
sessionId
|
|
? (chatSessionViewCache.get(sessionId)?.turns ?? [])
|
|
: initialConversationCacheKey
|
|
? (chatConversationViewCache.get(initialConversationCacheKey)?.turns ?? [])
|
|
: [],
|
|
);
|
|
const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(
|
|
() =>
|
|
sessionId
|
|
? (chatSessionViewCache.get(sessionId)?.progressIndex ?? new Map())
|
|
: initialConversationCacheKey
|
|
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map())
|
|
: new Map(),
|
|
);
|
|
const [progressScope, setProgressScope] = useState<number>(
|
|
() =>
|
|
sessionId
|
|
? (chatSessionViewCache.get(sessionId)?.progressScope ?? 0)
|
|
: initialConversationCacheKey
|
|
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressScope ?? 0)
|
|
: 0,
|
|
);
|
|
const [expandedProgressKeys, setExpandedProgressKeys] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
|
const [draft, setDraft] = useState("");
|
|
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
|
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
|
|
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
|
const [profileDialog, setProfileDialog] =
|
|
useState<ProfileCommandDialogState | null>(null);
|
|
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
|
const [openRetryNonce, setOpenRetryNonce] = useState(0);
|
|
const [busy, setBusy] = useState(false);
|
|
const [stopping, setStopping] = useState(false);
|
|
const [error, setError] = useState<ChatErrorState | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const chatRootRef = useRef<HTMLDivElement | null>(null);
|
|
const turnsRef = useRef(turns);
|
|
const progressIndexRef = useRef(progressIndex);
|
|
const progressScopeRef = useRef(progressScope);
|
|
const sessionRef = useRef<string | null>(sessionId);
|
|
sessionRef.current = currentSession;
|
|
const scrollbackChunkCountRef = useRef(
|
|
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
|
|
);
|
|
const detachAgentChatRef = useRef<((sessionId: string) => Promise<void>) | undefined>(
|
|
agent.detachAgentChat ? (sid) => agent.detachAgentChat!(sid) : undefined,
|
|
);
|
|
detachAgentChatRef.current = agent.detachAgentChat
|
|
? (sid) => agent.detachAgentChat!(sid)
|
|
: undefined;
|
|
const slashQuerySeqRef = useRef(0);
|
|
const draftSaveSeqRef = useRef(0);
|
|
const interactionSeqRef = useRef(0);
|
|
const activeInteractionIdRef = useRef<string | null>(null);
|
|
const draftHydratedRef = useRef(false);
|
|
const draftEditedBeforeHydrationRef = useRef(false);
|
|
const lastPersistedDraftRef = useRef("");
|
|
const draftRef = useRef("");
|
|
draftRef.current = draft;
|
|
const draftBeforeHistoryNavigationRef = useRef("");
|
|
const attachmentsRef = useRef<AttachmentDraft[]>([]);
|
|
attachmentsRef.current = attachments;
|
|
const imageTokenSeqRef = useRef(0);
|
|
const mountedRef = useRef(false);
|
|
const openOrAttachCountRef = useRef(0);
|
|
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
|
|
const reconciledRestartIdsRef = useRef(new Set<string>());
|
|
const onSessionIdRef = useRef(onSessionId);
|
|
onSessionIdRef.current = onSessionId;
|
|
const onConversationIdRef = useRef(onConversationId);
|
|
onConversationIdRef.current = onConversationId;
|
|
const domId = nodeId ?? `agent-${agentId}`;
|
|
|
|
const clampComposerHeight = useCallback(
|
|
(heightPx: number): number => {
|
|
const visibleHeight =
|
|
chatRootRef.current?.getBoundingClientRect().height ??
|
|
composerRef.current?.closest<HTMLElement>("[data-custom-chat-shell]")
|
|
?.getBoundingClientRect().height ??
|
|
0;
|
|
const maxHeight =
|
|
visibleHeight > 0
|
|
? Math.max(composerDefaultHeightPx, Math.floor(visibleHeight * 0.5))
|
|
: Number.POSITIVE_INFINITY;
|
|
return Math.round(
|
|
Math.min(Math.max(heightPx, composerDefaultHeightPx), maxHeight),
|
|
);
|
|
},
|
|
[composerDefaultHeightPx],
|
|
);
|
|
|
|
const commitComposerHeight = useCallback(
|
|
(heightPx: number): void => {
|
|
onComposerHeightPxChange?.(clampComposerHeight(heightPx));
|
|
},
|
|
[clampComposerHeight, onComposerHeightPxChange],
|
|
);
|
|
|
|
const startComposerResize = useCallback(
|
|
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const startY = event.clientY;
|
|
const startHeight = composerHeightPx;
|
|
const onMove = (moveEvent: MouseEvent) => {
|
|
commitComposerHeight(startHeight + startY - moveEvent.clientY);
|
|
};
|
|
const onUp = () => {
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
};
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
},
|
|
[commitComposerHeight, composerHeightPx],
|
|
);
|
|
|
|
const handleComposerResizeKeyDown = useCallback(
|
|
(event: KeyboardEvent<HTMLButtonElement>) => {
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
commitComposerHeight(composerHeightPx + COMPOSER_RESIZE_STEP);
|
|
} else if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
commitComposerHeight(composerHeightPx - COMPOSER_RESIZE_STEP);
|
|
}
|
|
},
|
|
[commitComposerHeight, composerHeightPx],
|
|
);
|
|
|
|
const applyChatViewState = useCallback((next: ChatViewState) => {
|
|
turnsRef.current = next.turns;
|
|
progressIndexRef.current = next.progressIndex;
|
|
progressScopeRef.current = next.progressScope;
|
|
setTurns(next.turns);
|
|
setProgressIndex(next.progressIndex);
|
|
setProgressScope(next.progressScope);
|
|
}, []);
|
|
|
|
const updateChatViewState = useCallback(
|
|
(updater: (prev: ChatViewState) => ChatViewState) => {
|
|
applyChatViewState(
|
|
updater({
|
|
turns: turnsRef.current,
|
|
progressIndex: progressIndexRef.current,
|
|
progressScope: progressScopeRef.current,
|
|
}),
|
|
);
|
|
},
|
|
[applyChatViewState],
|
|
);
|
|
|
|
const supported = Boolean(
|
|
profile.structuredAdapter &&
|
|
agent.launchAgentChat &&
|
|
agent.reattachAgentChat &&
|
|
agent.sendAgentChat &&
|
|
agent.cancelAgentChat &&
|
|
agent.closeAgentChat,
|
|
);
|
|
const slashPrefix = slashCommandPrefix(draft);
|
|
const draftScope = useMemo<ChatDraftScope | null>(() => {
|
|
if (explicitDraftScope) return explicitDraftScope;
|
|
return layoutId && nodeId ? { projectId, layoutId, nodeId } : null;
|
|
}, [explicitDraftScope, layoutId, nodeId, projectId]);
|
|
const currentConversationCacheKey = useMemo(
|
|
() => conversationCacheKey(projectId, agentId, conversationId),
|
|
[agentId, conversationId, projectId],
|
|
);
|
|
const composerHistoryKey = useMemo(
|
|
() => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId),
|
|
[agentId, layoutId, nodeId, projectId],
|
|
);
|
|
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
|
|
readComposerHistory(composerHistoryKey),
|
|
);
|
|
const [, setComposerHistoryIndex] = useState<number | null>(null);
|
|
const displayTurns = turns;
|
|
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
|
|
const [requesterDelegation, setRequesterDelegation] =
|
|
useState<RequesterDelegationState>({
|
|
currentAgentBusy: false,
|
|
delegationTargetName: null,
|
|
});
|
|
const { restart, events: restartEvents } = useAgentRestart(agentId);
|
|
const delegationActive = requesterDelegation.delegationTargetName !== null;
|
|
const effectiveBusy =
|
|
busy ||
|
|
stopping ||
|
|
Boolean(restart && restart.outcome !== "failed") ||
|
|
requesterDelegation.currentAgentBusy ||
|
|
delegationActive;
|
|
const delegationFallbackText = requesterDelegation.delegationTargetName
|
|
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
|
|
: "Délégation en cours.";
|
|
|
|
const refreshRequesterDelegation = useCallback(async () => {
|
|
if (!workState) {
|
|
setRequesterDelegation({
|
|
currentAgentBusy: false,
|
|
delegationTargetName: null,
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const state = await workState.getProjectWorkState(projectId);
|
|
const current = state.agents.find((row) => row.agentId === agentId);
|
|
const currentAgentBusy = Boolean(
|
|
current?.busy.state === "busy" ||
|
|
current?.tickets.some((ticket) => ticket.status === "inProgress"),
|
|
);
|
|
const target = state.agents.find((row) =>
|
|
row.tickets.some(
|
|
(ticket) =>
|
|
ticket.status === "inProgress" &&
|
|
ticket.source.kind === "agent" &&
|
|
ticket.source.agentId === agentId,
|
|
),
|
|
);
|
|
setRequesterDelegation({
|
|
currentAgentBusy,
|
|
delegationTargetName: target ? workAgentDisplayName(target) : null,
|
|
});
|
|
} catch {
|
|
setRequesterDelegation({
|
|
currentAgentBusy: false,
|
|
delegationTargetName: null,
|
|
});
|
|
}
|
|
}, [agentId, projectId, workState]);
|
|
|
|
const clearPersistedDraft = useCallback(async () => {
|
|
if (!draftScope) return;
|
|
draftSaveSeqRef.current += 1;
|
|
lastPersistedDraftRef.current = "";
|
|
try {
|
|
if (agent.clearChatDraft) {
|
|
await agent.clearChatDraft(draftScope);
|
|
} else if (agent.saveChatDraft) {
|
|
await agent.saveChatDraft(draftScope, "");
|
|
}
|
|
} catch (e) {
|
|
console.warn("[chat-draft] clear failed", instrumentationError(e));
|
|
}
|
|
}, [agent, draftScope]);
|
|
|
|
const editDraft = useCallback((text: string) => {
|
|
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
|
setComposerHistoryIndex(null);
|
|
draftBeforeHistoryNavigationRef.current = "";
|
|
setDraft(text);
|
|
}, []);
|
|
|
|
const recallComposerHistory = useCallback(
|
|
(direction: "previous" | "next") => {
|
|
if (composerHistory.length === 0) return;
|
|
setComposerHistoryIndex((current) => {
|
|
if (current === null && direction === "previous") {
|
|
draftBeforeHistoryNavigationRef.current = draftRef.current;
|
|
}
|
|
const nextIndex =
|
|
direction === "previous"
|
|
? current === null
|
|
? composerHistory.length - 1
|
|
: Math.max(0, current - 1)
|
|
: current === null
|
|
? null
|
|
: current >= composerHistory.length - 1
|
|
? null
|
|
: current + 1;
|
|
if (nextIndex === null) {
|
|
setDraft(draftBeforeHistoryNavigationRef.current);
|
|
draftBeforeHistoryNavigationRef.current = "";
|
|
} else {
|
|
setDraft(composerHistory[nextIndex] ?? "");
|
|
}
|
|
return nextIndex;
|
|
});
|
|
},
|
|
[composerHistory],
|
|
);
|
|
|
|
const handleComposerHistoryKeyDown = useCallback(
|
|
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
|
if (
|
|
event.altKey ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
event.shiftKey ||
|
|
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
|
) {
|
|
return false;
|
|
}
|
|
const target = event.currentTarget;
|
|
if (
|
|
event.key === "ArrowUp" &&
|
|
target.value.slice(0, target.selectionStart).includes("\n")
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
event.key === "ArrowDown" &&
|
|
target.value.slice(target.selectionEnd).includes("\n")
|
|
) {
|
|
return false;
|
|
}
|
|
event.preventDefault();
|
|
recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next");
|
|
return true;
|
|
},
|
|
[recallComposerHistory],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const restored = readComposerHistory(composerHistoryKey);
|
|
setComposerHistory(restored);
|
|
setComposerHistoryIndex(null);
|
|
draftBeforeHistoryNavigationRef.current = "";
|
|
}, [composerHistoryKey]);
|
|
|
|
useEffect(() => {
|
|
writeComposerHistory(composerHistoryKey, composerHistory);
|
|
}, [composerHistory, composerHistoryKey]);
|
|
|
|
useEffect(() => {
|
|
void refreshRequesterDelegation();
|
|
}, [refreshRequesterDelegation]);
|
|
|
|
useEffect(() => {
|
|
if (!system?.onDomainEvent) return;
|
|
let unsubscribe: (() => void) | undefined;
|
|
let cancelled = false;
|
|
void system
|
|
.onDomainEvent((event) => {
|
|
if (
|
|
event.type === "agentBusyChanged" ||
|
|
event.type === "delegationReady" ||
|
|
event.type === "orchestratorRequestProcessed" ||
|
|
event.type === "agentInboxChanged" ||
|
|
event.type === "agentWakeChanged"
|
|
) {
|
|
if (
|
|
event.type === "agentBusyChanged" &&
|
|
event.agentId === agentId &&
|
|
!event.busy
|
|
) {
|
|
if (activeInteractionIdRef.current) {
|
|
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
|
|
activeInteractionIdRef.current = null;
|
|
}
|
|
setBusy(false);
|
|
}
|
|
void refreshRequesterDelegation();
|
|
}
|
|
})
|
|
.then((un) => {
|
|
if (cancelled) un();
|
|
else unsubscribe = un;
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
unsubscribe?.();
|
|
};
|
|
}, [agentId, projectId, refreshRequesterDelegation, system]);
|
|
|
|
useEffect(() => {
|
|
if (restartEvents.length === 0) return;
|
|
updateChatViewState((previous) => {
|
|
const nextTurns = [...previous.turns];
|
|
let changed = false;
|
|
for (const event of restartEvents) {
|
|
const key = `${event.restartId}:${event.type}`;
|
|
if (nextTurns.some((turn) => turn.role === "restart" && turn.key === key)) {
|
|
continue;
|
|
}
|
|
if (event.type === "agentRestartRequested") {
|
|
changed = true;
|
|
nextTurns.push({
|
|
role: "restart",
|
|
key,
|
|
text: "------- redemarrage de l'agent -------",
|
|
});
|
|
} else if (event.type === "agentRestartCompleted") {
|
|
changed = true;
|
|
nextTurns.push({
|
|
role: "restart",
|
|
key,
|
|
text: "------- redemarrage de l'agent termine -------",
|
|
});
|
|
} else {
|
|
changed = true;
|
|
nextTurns.push({
|
|
role: "restart",
|
|
key,
|
|
text: "------- redemarrage de l'agent echoue -------",
|
|
message: shortRestartFailure(event.message),
|
|
failed: true,
|
|
});
|
|
}
|
|
}
|
|
return changed ? { ...previous, turns: nextTurns } : previous;
|
|
});
|
|
}, [restartEvents, updateChatViewState]);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
const sid = sessionRef.current;
|
|
if (!sid) return;
|
|
void detachAgentChatRef.current?.(sid).catch((e) => {
|
|
if (!isNotFound(e)) {
|
|
console.warn("[ticket188] detachAgentChat failed", instrumentationError(e));
|
|
}
|
|
});
|
|
};
|
|
}, []);
|
|
|
|
const receive = useCallback(
|
|
(chunk: ReplyChunk) => {
|
|
scrollbackChunkCountRef.current += 1;
|
|
const activeInteractionId = activeInteractionIdRef.current;
|
|
if (
|
|
activeInteractionId &&
|
|
chunk.kind === "progress" &&
|
|
chunk.progress.kind === "turn"
|
|
) {
|
|
if (
|
|
chunk.progress.stage === "started" ||
|
|
chunk.progress.stage === "delta"
|
|
) {
|
|
markProjectCliInteractionStarted(projectId, activeInteractionId);
|
|
}
|
|
if (chunk.progress.stage === "completed") {
|
|
markProjectCliInteractionFinished(projectId, activeInteractionId);
|
|
activeInteractionIdRef.current = null;
|
|
}
|
|
}
|
|
updateChatViewState((prev) => foldChunk(prev, chunk));
|
|
if (chunk.kind === "final" || chunk.kind === "error") {
|
|
if (activeInteractionIdRef.current) {
|
|
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
|
|
activeInteractionIdRef.current = null;
|
|
}
|
|
setBusy(false);
|
|
}
|
|
},
|
|
[projectId, updateChatViewState],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (currentSession) {
|
|
chatSessionViewCache.set(currentSession, {
|
|
turns,
|
|
progressIndex,
|
|
progressScope,
|
|
chunkCount: scrollbackChunkCountRef.current,
|
|
sessionId: currentSession,
|
|
});
|
|
}
|
|
if (currentConversationCacheKey) {
|
|
const previous = chatConversationViewCache.get(currentConversationCacheKey);
|
|
const cachedSessionId = currentSession ?? previous?.sessionId;
|
|
if (cachedSessionId || turns.length > 0 || progressIndex.size > 0) {
|
|
chatConversationViewCache.set(currentConversationCacheKey, {
|
|
turns,
|
|
progressIndex,
|
|
progressScope,
|
|
chunkCount: scrollbackChunkCountRef.current,
|
|
...(cachedSessionId ? { sessionId: cachedSessionId } : {}),
|
|
});
|
|
}
|
|
}
|
|
}, [currentConversationCacheKey, currentSession, progressIndex, progressScope, turns]);
|
|
|
|
useEffect(() => {
|
|
draftSaveSeqRef.current += 1;
|
|
draftHydratedRef.current = false;
|
|
draftEditedBeforeHydrationRef.current = false;
|
|
lastPersistedDraftRef.current = "";
|
|
setDraft("");
|
|
if (!draftScope || !agent.readChatDraft) {
|
|
draftHydratedRef.current = true;
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
agent
|
|
.readChatDraft(draftScope)
|
|
.then((snapshot) => {
|
|
if (cancelled || !mountedRef.current) return;
|
|
const restored = snapshot.text ?? "";
|
|
lastPersistedDraftRef.current = restored;
|
|
if (!draftEditedBeforeHydrationRef.current) {
|
|
setDraft(restored);
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
if (!cancelled) {
|
|
console.warn("[chat-draft] read failed", instrumentationError(e));
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
draftHydratedRef.current = true;
|
|
if (
|
|
draftEditedBeforeHydrationRef.current &&
|
|
agent.saveChatDraft &&
|
|
draftRef.current !== lastPersistedDraftRef.current
|
|
) {
|
|
const text = draftRef.current;
|
|
void agent
|
|
.saveChatDraft(draftScope, text)
|
|
.then(() => {
|
|
lastPersistedDraftRef.current = text;
|
|
})
|
|
.catch((e) => {
|
|
console.warn("[chat-draft] save failed", instrumentationError(e));
|
|
});
|
|
}
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [agent, draftScope]);
|
|
|
|
useEffect(() => {
|
|
if (!draftScope || !agent.saveChatDraft || !draftHydratedRef.current) return;
|
|
if (draft === lastPersistedDraftRef.current) return;
|
|
|
|
const seq = ++draftSaveSeqRef.current;
|
|
const timeout = window.setTimeout(() => {
|
|
if (draftSaveSeqRef.current !== seq) return;
|
|
agent
|
|
.saveChatDraft!(draftScope, draft)
|
|
.then(() => {
|
|
if (draftSaveSeqRef.current === seq) {
|
|
lastPersistedDraftRef.current = draft;
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
console.warn("[chat-draft] save failed", instrumentationError(e));
|
|
});
|
|
}, 250);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [agent, draft, draftScope]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!supported ||
|
|
!agent.listSlashCommands ||
|
|
slashPrefix === null ||
|
|
effectiveBusy ||
|
|
opening
|
|
) {
|
|
setSlashMenuOpen(false);
|
|
setSlashCommands([]);
|
|
setSlashActiveIndex(0);
|
|
return;
|
|
}
|
|
|
|
const seq = ++slashQuerySeqRef.current;
|
|
agent
|
|
.listSlashCommands(slashPrefix)
|
|
.then((commands) => {
|
|
if (slashQuerySeqRef.current !== seq) return;
|
|
setSlashCommands(commands);
|
|
setSlashMenuOpen(commands.length > 0);
|
|
const firstAvailable = commands.findIndex(isSlashCommandAvailable);
|
|
setSlashActiveIndex(firstAvailable >= 0 ? firstAvailable : 0);
|
|
})
|
|
.catch(() => {
|
|
if (slashQuerySeqRef.current !== seq) return;
|
|
setSlashCommands([]);
|
|
setSlashMenuOpen(false);
|
|
setSlashActiveIndex(0);
|
|
});
|
|
}, [agent, effectiveBusy, opening, slashPrefix, supported]);
|
|
|
|
const selectSlashCommand = useCallback((command: SlashCommand) => {
|
|
if (!isSlashCommandAvailable(command)) return;
|
|
editDraft(`${command.name} `);
|
|
setSlashMenuOpen(false);
|
|
setSlashCommands([]);
|
|
setSlashActiveIndex(0);
|
|
requestAnimationFrame(() => composerRef.current?.focus());
|
|
}, [editDraft]);
|
|
|
|
const publishSessionId = useCallback((nextSessionId: string | null) => {
|
|
selfEmittedSessionIdRef.current = nextSessionId;
|
|
onSessionIdRef.current(nextSessionId);
|
|
}, []);
|
|
|
|
const reattachStructuredSession = useCallback(
|
|
async (
|
|
sid: string,
|
|
options: {
|
|
applyScrollback?: boolean;
|
|
stableIdentity?: boolean;
|
|
preserveRestartTurns?: boolean;
|
|
} = {},
|
|
) => {
|
|
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
|
|
console.debug("[ticket149] reattachStructuredSession:start", {
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: sid,
|
|
options,
|
|
});
|
|
let reattached: Awaited<ReturnType<NonNullable<typeof agent.reattachAgentChat>>>;
|
|
try {
|
|
reattached = options.stableIdentity
|
|
? await agent.reattachAgentChat(sid, receive, { projectId, agentId })
|
|
: await agent.reattachAgentChat(sid, receive);
|
|
} catch (e) {
|
|
console.error("[ticket149] reattachStructuredSession:error", {
|
|
timestamp: new Date().toISOString(),
|
|
sessionId: sid,
|
|
error: instrumentationError(e),
|
|
});
|
|
throw e;
|
|
}
|
|
console.debug("[ticket149] reattachStructuredSession:success", {
|
|
timestamp: new Date().toISOString(),
|
|
requestedSessionId: sid,
|
|
returnedSessionId: reattached.sessionId,
|
|
scrollbackCount: reattached.scrollback.length,
|
|
});
|
|
if (!mountedRef.current) return reattached.sessionId;
|
|
setCurrentSession(reattached.sessionId);
|
|
if (reattached.sessionId !== sid) {
|
|
publishSessionId(reattached.sessionId);
|
|
}
|
|
if (reattached.conversationId) {
|
|
onConversationIdRef.current(reattached.conversationId);
|
|
}
|
|
if (options.applyScrollback) {
|
|
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
|
let replayed = reattached.scrollback.reduce(
|
|
(state, chunk) => foldChunk(state, chunk),
|
|
emptyChatViewState(),
|
|
);
|
|
if (options.preserveRestartTurns) {
|
|
const restartTurns = turnsRef.current.filter((turn) => turn.role === "restart");
|
|
if (restartTurns.length > 0) {
|
|
replayed = { ...replayed, turns: [...replayed.turns, ...restartTurns] };
|
|
}
|
|
}
|
|
applyChatViewState(replayed);
|
|
setBusy(turnsIndicateBusy(replayed));
|
|
}
|
|
return reattached.sessionId;
|
|
},
|
|
[agent, agentId, applyChatViewState, projectId, publishSessionId, receive],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const completed = [...restartEvents]
|
|
.reverse()
|
|
.find((event) => event.type === "agentRestartCompleted");
|
|
if (!completed || reconciledRestartIdsRef.current.has(completed.restartId)) return;
|
|
const sid = sessionRef.current ?? externalSessionId;
|
|
if (!sid) return;
|
|
|
|
reconciledRestartIdsRef.current.add(completed.restartId);
|
|
let cancelled = false;
|
|
setOpening(true);
|
|
setError(null);
|
|
void reattachStructuredSession(sid, {
|
|
applyScrollback: true,
|
|
stableIdentity: true,
|
|
preserveRestartTurns: true,
|
|
})
|
|
.catch((e) => {
|
|
if (!cancelled) {
|
|
setError({ message: describeOpenFailure(e), openFailure: true });
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setOpening(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [externalSessionId, reattachStructuredSession, restartEvents]);
|
|
|
|
const recoverStructuredSession = useCallback(
|
|
async (
|
|
options: {
|
|
applyScrollback?: boolean;
|
|
retryAttachNotFound?: boolean;
|
|
conversationId?: string | null;
|
|
} = {},
|
|
) => {
|
|
if (!agent.launchAgentChat) throw new Error("Structured launch unavailable");
|
|
console.debug("[ticket149] recoverStructuredSession:start", {
|
|
timestamp: new Date().toISOString(),
|
|
currentSession: sessionRef.current,
|
|
conversationId,
|
|
options,
|
|
});
|
|
let retryAttachNotFound = Boolean(options.retryAttachNotFound);
|
|
for (;;) {
|
|
const launchRequest = {
|
|
cwd,
|
|
rows: 24,
|
|
cols: 80,
|
|
conversationId:
|
|
options.conversationId === undefined
|
|
? conversationId ?? undefined
|
|
: options.conversationId ?? undefined,
|
|
...(nodeId ? { nodeId } : {}),
|
|
};
|
|
console.debug("[ticket149] recoverStructuredSession:launchAgentChat:start", {
|
|
timestamp: new Date().toISOString(),
|
|
projectId,
|
|
agentId,
|
|
request: launchRequest,
|
|
options,
|
|
retryAttachNotFound,
|
|
});
|
|
let launched: Awaited<ReturnType<NonNullable<typeof agent.launchAgentChat>>>;
|
|
try {
|
|
launched = await agent.launchAgentChat(projectId, agentId, launchRequest);
|
|
} catch (e) {
|
|
console.error("[ticket149] recoverStructuredSession:launchAgentChat:error", {
|
|
timestamp: new Date().toISOString(),
|
|
projectId,
|
|
agentId,
|
|
request: launchRequest,
|
|
error: instrumentationError(e),
|
|
});
|
|
throw e;
|
|
}
|
|
console.debug("[ticket149] recoverStructuredSession:launchAgentChat:success", {
|
|
timestamp: new Date().toISOString(),
|
|
projectId,
|
|
agentId,
|
|
launched,
|
|
});
|
|
if (!mountedRef.current) return launched.sessionId;
|
|
scrollbackChunkCountRef.current = 0;
|
|
setCurrentSession(launched.sessionId);
|
|
publishSessionId(launched.sessionId);
|
|
if (launched.assignedConversationId) {
|
|
onConversationIdRef.current(launched.assignedConversationId);
|
|
}
|
|
try {
|
|
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
|
|
applyScrollback: options.applyScrollback,
|
|
});
|
|
console.debug("[ticket149] recoverStructuredSession:success", {
|
|
timestamp: new Date().toISOString(),
|
|
launchedSessionId: launched.sessionId,
|
|
recoveredSessionId,
|
|
});
|
|
return recoveredSessionId;
|
|
} catch (e) {
|
|
if (isNotFound(e) && retryAttachNotFound) {
|
|
retryAttachNotFound = false;
|
|
if (mountedRef.current) {
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
}
|
|
continue;
|
|
}
|
|
console.error("[ticket149] recoverStructuredSession:reattach:error", {
|
|
timestamp: new Date().toISOString(),
|
|
launchedSessionId: launched.sessionId,
|
|
error: instrumentationError(e),
|
|
});
|
|
throw e;
|
|
}
|
|
}
|
|
},
|
|
[
|
|
agent,
|
|
projectId,
|
|
agentId,
|
|
cwd,
|
|
nodeId,
|
|
conversationId,
|
|
reattachStructuredSession,
|
|
publishSessionId,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}, [displayTurns]);
|
|
|
|
useEffect(() => {
|
|
if (selfEmittedSessionIdRef.current === sessionId) {
|
|
console.debug("[ticket149] openOrAttach:self-session-echo:skip", {
|
|
timestamp: new Date().toISOString(),
|
|
sessionId,
|
|
});
|
|
selfEmittedSessionIdRef.current = undefined;
|
|
return;
|
|
}
|
|
setExternalSessionId(sessionId);
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!supported) return;
|
|
let cancelled = false;
|
|
|
|
async function openOrAttach() {
|
|
const execution = openOrAttachCountRef.current + 1;
|
|
openOrAttachCountRef.current = execution;
|
|
console.debug("[ticket149] openOrAttach:start", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
sessionId: externalSessionId,
|
|
});
|
|
setOpening(true);
|
|
setError(null);
|
|
try {
|
|
const localSession = sessionRef.current;
|
|
if (!externalSessionId && localSession) {
|
|
console.debug("[ticket149] openOrAttach:local-session-present:skip", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
currentSession: localSession,
|
|
});
|
|
return;
|
|
}
|
|
if (externalSessionId) {
|
|
const cached = chatSessionViewCache.get(externalSessionId);
|
|
if (cached) {
|
|
scrollbackChunkCountRef.current = cached.chunkCount;
|
|
applyChatViewState({
|
|
turns: cached.turns,
|
|
progressIndex: cached.progressIndex ?? new Map(),
|
|
progressScope: cached.progressScope ?? 0,
|
|
});
|
|
}
|
|
try {
|
|
await reattachStructuredSession(externalSessionId, { applyScrollback: true });
|
|
if (cancelled) return;
|
|
console.debug("[ticket149] openOrAttach:reattach-existing:success", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
sessionId: externalSessionId,
|
|
});
|
|
return;
|
|
} catch (e) {
|
|
// The backend contract for `reattach_agent_chat` (NOT_FOUND) is that
|
|
// the session is gone (closed/never live) and the caller falls back
|
|
// to a fresh launch — any other error still surfaces to the user.
|
|
if (!isNotFound(e)) throw e;
|
|
try {
|
|
const replacementSessionId = await reattachStructuredSession(externalSessionId, {
|
|
applyScrollback: true,
|
|
stableIdentity: true,
|
|
});
|
|
if (cancelled) return;
|
|
publishSessionId(replacementSessionId);
|
|
console.debug("[ticket295] openOrAttach:stable-reattach:success", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
requestedSessionId: externalSessionId,
|
|
replacementSessionId,
|
|
});
|
|
return;
|
|
} catch (stableError) {
|
|
if (!isNotFound(stableError)) throw stableError;
|
|
}
|
|
if (!cancelled) {
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
}
|
|
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
sessionId: externalSessionId,
|
|
error: instrumentationError(e),
|
|
});
|
|
}
|
|
} else if (currentConversationCacheKey) {
|
|
const cached = chatConversationViewCache.get(currentConversationCacheKey);
|
|
if (cached) {
|
|
scrollbackChunkCountRef.current = cached.chunkCount;
|
|
applyChatViewState({
|
|
turns: cached.turns,
|
|
progressIndex: cached.progressIndex ?? new Map(),
|
|
progressScope: cached.progressScope ?? 0,
|
|
});
|
|
if (cached.sessionId) {
|
|
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
|
|
applyScrollback: true,
|
|
});
|
|
if (cancelled) return;
|
|
publishSessionId(reattachedSessionId);
|
|
console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
sessionId: reattachedSessionId,
|
|
});
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
await recoverStructuredSession({
|
|
applyScrollback: Boolean(conversationId),
|
|
retryAttachNotFound: true,
|
|
});
|
|
if (cancelled) return;
|
|
console.debug("[ticket149] openOrAttach:recover:success", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
receivedSessionId: externalSessionId,
|
|
});
|
|
} catch (e) {
|
|
console.error("[ticket149] openOrAttach:error", {
|
|
timestamp: new Date().toISOString(),
|
|
execution,
|
|
sessionId: externalSessionId,
|
|
error: instrumentationError(e),
|
|
});
|
|
if (!cancelled) {
|
|
setError({ message: describeOpenFailure(e), openFailure: true });
|
|
}
|
|
} finally {
|
|
if (!cancelled) setOpening(false);
|
|
}
|
|
}
|
|
|
|
void openOrAttach();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
supported,
|
|
externalSessionId,
|
|
reattachStructuredSession,
|
|
recoverStructuredSession,
|
|
publishSessionId,
|
|
applyChatViewState,
|
|
currentConversationCacheKey,
|
|
conversationId,
|
|
openRetryNonce,
|
|
]);
|
|
|
|
const canSend = useMemo(
|
|
() =>
|
|
supported &&
|
|
(Boolean(draft.trim()) || attachments.length > 0) &&
|
|
!effectiveBusy &&
|
|
!opening,
|
|
[supported, draft, attachments.length, effectiveBusy, opening],
|
|
);
|
|
const hasLinkedConversation = Boolean(
|
|
conversationId ||
|
|
requesterDelegation.currentAgentBusy ||
|
|
restart ||
|
|
delegationActive,
|
|
);
|
|
|
|
async function openProfileDialog() {
|
|
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
|
Boolean(candidate.structuredAdapter),
|
|
);
|
|
if (availableProfiles.length === 0) {
|
|
throw new Error("Aucun profil compatible avec la CLI custom n'est configuré.");
|
|
}
|
|
setDraft("");
|
|
setProfileDialog({
|
|
profiles: availableProfiles,
|
|
selectedProfileId:
|
|
availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ??
|
|
availableProfiles[0].id,
|
|
confirmedReset: false,
|
|
busy: false,
|
|
error: null,
|
|
});
|
|
}
|
|
|
|
async function confirmProfileCommandChange() {
|
|
if (!profileDialog) return;
|
|
if (!agent.changeAgentProfile) {
|
|
setProfileDialog((prev) =>
|
|
prev ? { ...prev, error: "Changement de profil indisponible." } : prev,
|
|
);
|
|
return;
|
|
}
|
|
const selected = profileDialog.profiles.find(
|
|
(candidate) => candidate.id === profileDialog.selectedProfileId,
|
|
);
|
|
if (!selected) return;
|
|
|
|
setProfileDialog((prev) => (prev ? { ...prev, busy: true, error: null } : prev));
|
|
try {
|
|
const { relaunchedSession } = await agent.changeAgentProfile(
|
|
projectId,
|
|
agentId,
|
|
selected.id,
|
|
24,
|
|
80,
|
|
);
|
|
applyChatViewState(emptyChatViewState());
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
onConversationIdRef.current(null);
|
|
|
|
if (relaunchedSession?.sessionId) {
|
|
try {
|
|
setCurrentSession(relaunchedSession.sessionId);
|
|
publishSessionId(relaunchedSession.sessionId);
|
|
await reattachStructuredSession(relaunchedSession.sessionId, {
|
|
applyScrollback: false,
|
|
});
|
|
} catch {
|
|
await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
conversationId: null,
|
|
});
|
|
}
|
|
} else {
|
|
await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
conversationId: null,
|
|
});
|
|
}
|
|
|
|
applyChatViewState({
|
|
turns: [
|
|
{
|
|
role: "tool",
|
|
label: `Profil changé vers ${selected.name}.`,
|
|
receivedAtMs: Date.now(),
|
|
},
|
|
],
|
|
progressIndex: new Map(),
|
|
progressScope: 0,
|
|
});
|
|
setProfileDialog(null);
|
|
} catch (e) {
|
|
setProfileDialog((prev) =>
|
|
prev ? { ...prev, busy: false, error: describe(e) } : prev,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function executeSlashCommand(invocation: SlashCommandInvocation) {
|
|
if (!agent.executeSlashCommand) {
|
|
setError({
|
|
message: `Commande ${invocation.name} indisponible dans ce runtime.`,
|
|
openFailure: false,
|
|
});
|
|
return;
|
|
}
|
|
setDraft("");
|
|
void clearPersistedDraft();
|
|
setError(null);
|
|
try {
|
|
const sid =
|
|
currentSession ??
|
|
(await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
}));
|
|
const result = await agent.executeSlashCommand(invocation.name, {
|
|
sessionId: sid,
|
|
...(invocation.arguments.length > 0
|
|
? { arguments: invocation.arguments }
|
|
: {}),
|
|
});
|
|
if (result.effect.kind === "help") {
|
|
const helpText = formatSlashCommandHelp(result.effect.commands);
|
|
updateChatViewState((prev) => ({
|
|
...prev,
|
|
turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }],
|
|
}));
|
|
return;
|
|
}
|
|
if (result.effect.kind === "cleanConversation") {
|
|
applyChatViewState(emptyChatViewState());
|
|
return;
|
|
}
|
|
if (result.effect.kind === "profileSwitch") {
|
|
await openProfileDialog();
|
|
return;
|
|
}
|
|
if (result.effect.kind === "pluginCallback") {
|
|
await pluginRuntime.registry.runCommandStrict(
|
|
result.effect.pluginId,
|
|
result.effect.commandId,
|
|
...result.effect.arguments,
|
|
);
|
|
updateChatViewState((prev) => ({
|
|
...prev,
|
|
turns: [
|
|
...prev.turns,
|
|
{
|
|
role: "tool",
|
|
label: `Commande plugin ${result.command.name} exécutée.`,
|
|
receivedAtMs: Date.now(),
|
|
},
|
|
],
|
|
}));
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
|
setError({ message, openFailure: false });
|
|
updateChatViewState((prev) => ({
|
|
...prev,
|
|
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
|
|
}));
|
|
}
|
|
}
|
|
|
|
async function pickAttachment() {
|
|
const path = await system.pickFile();
|
|
if (path) {
|
|
setAttachments((prev) => [
|
|
...prev,
|
|
{
|
|
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
label: pathBasename(path),
|
|
input: { path, sourceKind: "localFile" },
|
|
},
|
|
]);
|
|
}
|
|
}
|
|
|
|
function removeAttachment(item: AttachmentDraft) {
|
|
setAttachments((prev) =>
|
|
prev.filter((candidate) => candidate.id !== item.id),
|
|
);
|
|
if (item.token) {
|
|
editDraft(removeAttachmentToken(draftRef.current, item.token));
|
|
}
|
|
}
|
|
|
|
async function pasteClipboardFiles(
|
|
event: ClipboardEvent<HTMLTextAreaElement>,
|
|
) {
|
|
const target = event.currentTarget;
|
|
const pastedText = event.clipboardData.getData("text/plain");
|
|
const selectionStart = target.selectionStart;
|
|
const selectionEnd = target.selectionEnd;
|
|
const filesFromEvent = dataTransferFiles(event.clipboardData);
|
|
try {
|
|
const files =
|
|
filesFromEvent.length > 0 ? filesFromEvent : await asyncClipboardImageFiles();
|
|
if (files.length === 0) return;
|
|
|
|
if (filesFromEvent.length > 0) event.preventDefault();
|
|
const nextAttachments = await Promise.all(
|
|
files.map(clipboardFileToAttachment),
|
|
);
|
|
const known = new Set(
|
|
attachmentsRef.current
|
|
.map((attachment) => attachment.dedupeKey)
|
|
.filter((key): key is string => Boolean(key)),
|
|
);
|
|
const uniqueAttachments = nextAttachments
|
|
.filter((attachment) => {
|
|
if (!attachment.dedupeKey) return true;
|
|
if (known.has(attachment.dedupeKey)) return false;
|
|
known.add(attachment.dedupeKey);
|
|
return true;
|
|
})
|
|
.map((attachment) => {
|
|
if (!attachment.previewUrl) return attachment;
|
|
const token = `[Image ${++imageTokenSeqRef.current}]`;
|
|
return { ...attachment, token };
|
|
});
|
|
if (uniqueAttachments.length > 0) {
|
|
setAttachments((prev) => [...prev, ...uniqueAttachments]);
|
|
}
|
|
const imageTokens = uniqueAttachments
|
|
.map((attachment) => attachment.token)
|
|
.filter((token): token is string => Boolean(token));
|
|
const insertionParts = [
|
|
...(pastedText ? [pastedText] : []),
|
|
...(imageTokens.length > 0 ? [imageTokens.join(" ")] : []),
|
|
];
|
|
if (insertionParts.length > 0) {
|
|
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
|
setDraft((prev) =>
|
|
insertTextAtSelection(
|
|
prev,
|
|
insertionParts.join(" "),
|
|
selectionStart,
|
|
selectionEnd,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
setError({ message: describe(e), openFailure: false });
|
|
}
|
|
}
|
|
|
|
async function send() {
|
|
const text = draft.trim();
|
|
if (!canSend || !agent.sendAgentChat) return;
|
|
const slashInvocation = slashCommandInvocation(text);
|
|
if (slashInvocation) {
|
|
await executeSlashCommand(slashInvocation);
|
|
return;
|
|
}
|
|
const outgoingAttachments = attachments;
|
|
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
|
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
|
const displayText = text || "Pièce jointe";
|
|
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
|
|
activeInteractionIdRef.current = interactionId;
|
|
markProjectCliInteractionStarted(projectId, interactionId);
|
|
const finishInteraction = () => {
|
|
if (activeInteractionIdRef.current !== interactionId) return;
|
|
markProjectCliInteractionFinished(projectId, interactionId);
|
|
activeInteractionIdRef.current = null;
|
|
};
|
|
if (text) {
|
|
setComposerHistory((prev) => appendComposerHistoryEntry(prev, text));
|
|
setComposerHistoryIndex(null);
|
|
draftBeforeHistoryNavigationRef.current = "";
|
|
}
|
|
setDraft("");
|
|
setAttachments([]);
|
|
imageTokenSeqRef.current = 0;
|
|
void clearPersistedDraft();
|
|
setBusy(true);
|
|
setError(null);
|
|
const sentAtMs = Date.now();
|
|
updateChatViewState((prev) => ({
|
|
...prev,
|
|
turns: [
|
|
...prev.turns,
|
|
{
|
|
role: "user",
|
|
text: displayText,
|
|
receivedAtMs: sentAtMs,
|
|
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
|
},
|
|
],
|
|
progressScope: prev.progressScope + 1,
|
|
}));
|
|
const sendTurn = (sid: string) =>
|
|
attachmentInputs.length > 0
|
|
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
|
|
: agent.sendAgentChat!(sid, text, receive);
|
|
try {
|
|
const sid =
|
|
currentSession ??
|
|
(await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
}));
|
|
await sendTurn(sid);
|
|
finishInteraction();
|
|
} catch (e) {
|
|
if (isNotFound(e)) {
|
|
try {
|
|
setCurrentSession(null);
|
|
publishSessionId(null);
|
|
const recoveredSession = await recoverStructuredSession({
|
|
applyScrollback: false,
|
|
retryAttachNotFound: true,
|
|
});
|
|
await sendTurn(recoveredSession);
|
|
finishInteraction();
|
|
return;
|
|
} catch (recoveryError) {
|
|
finishInteraction();
|
|
setBusy(false);
|
|
setError({ message: describe(recoveryError), openFailure: false });
|
|
updateChatViewState((prev) => ({
|
|
...prev,
|
|
turns: [
|
|
...prev.turns,
|
|
{ role: "error", text: describe(recoveryError), receivedAtMs: Date.now() },
|
|
],
|
|
}));
|
|
return;
|
|
}
|
|
}
|
|
finishInteraction();
|
|
setBusy(false);
|
|
setError({ message: describe(e), openFailure: false });
|
|
updateChatViewState((prev) => ({
|
|
...prev,
|
|
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
|
|
}));
|
|
}
|
|
}
|
|
|
|
async function stopCurrentRequest() {
|
|
const sid = sessionRef.current;
|
|
if (!sid || !agent.cancelAgentChat || stopping) return;
|
|
setStopping(true);
|
|
setError(null);
|
|
try {
|
|
await agent.cancelAgentChat(sid);
|
|
if (activeInteractionIdRef.current) {
|
|
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
|
|
activeInteractionIdRef.current = null;
|
|
}
|
|
setBusy(false);
|
|
setOpening(false);
|
|
updateChatViewState((prev) => {
|
|
const completed = completeChatViewProgress(prev);
|
|
return {
|
|
...completed,
|
|
turns: [
|
|
...completed.turns,
|
|
{
|
|
role: "tool",
|
|
label: "Request stopped.",
|
|
receivedAtMs: Date.now(),
|
|
},
|
|
],
|
|
};
|
|
});
|
|
} catch (e) {
|
|
if (isNotFound(e)) {
|
|
if (activeInteractionIdRef.current) {
|
|
markProjectCliInteractionStopped(projectId, activeInteractionIdRef.current);
|
|
activeInteractionIdRef.current = null;
|
|
}
|
|
setBusy(false);
|
|
setOpening(false);
|
|
updateChatViewState((prev) => {
|
|
const completed = completeChatViewProgress(prev);
|
|
return {
|
|
...completed,
|
|
turns: [
|
|
...completed.turns,
|
|
{ role: "tool", label: "Request already stopped.", receivedAtMs: Date.now() },
|
|
],
|
|
};
|
|
});
|
|
return;
|
|
}
|
|
setError({ message: describe(e), openFailure: false });
|
|
updateChatViewState((prev) => ({
|
|
...prev,
|
|
turns: [
|
|
...prev.turns,
|
|
{
|
|
role: "error",
|
|
text: `Arrêt de la demande échoué: ${describe(e)}`,
|
|
receivedAtMs: Date.now(),
|
|
},
|
|
],
|
|
}));
|
|
} finally {
|
|
setStopping(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={chatRootRef}
|
|
data-testid="custom-agent-chat-view"
|
|
className="relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
|
|
>
|
|
<div
|
|
role="toolbar"
|
|
aria-label="custom agent chat actions"
|
|
className="relative z-20 flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
|
|
>
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
<div className="truncate text-sm font-medium">{agentName}</div>
|
|
<div className="truncate text-xs text-muted">
|
|
CLI custom · {profile.name}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{!supported && (
|
|
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
|
|
CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
|
|
>
|
|
{error.openFailure ? (
|
|
<div className="flex min-w-0 flex-col gap-2">
|
|
<div className="font-medium">Le CLI custom n'a pas pu démarrer.</div>
|
|
<div className="break-words">
|
|
Cause: {error.message}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
disabled={opening}
|
|
onClick={() => setOpenRetryNonce((value) => value + 1)}
|
|
>
|
|
Réessayer
|
|
</Button>
|
|
{onOpenNativeTui && (
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={onOpenNativeTui}
|
|
>
|
|
Ouvrir en TUI native
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
error.message
|
|
)}
|
|
</div>
|
|
)}
|
|
{profileDialog && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={`profile-command-title-${domId}`}
|
|
className={cn(
|
|
"border border-border bg-surface p-3 shadow-lg",
|
|
mobile
|
|
? "fixed inset-x-0 bottom-0 z-40 m-0 rounded-t-md pb-[max(0.75rem,env(safe-area-inset-bottom))]"
|
|
: "m-3 rounded-md",
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2
|
|
id={`profile-command-title-${domId}`}
|
|
className="text-sm font-semibold text-content"
|
|
>
|
|
Changer de profil
|
|
</h2>
|
|
<p className="mt-1 text-xs text-muted">
|
|
Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={profileDialog.busy}
|
|
onClick={() => setProfileDialog(null)}
|
|
>
|
|
Fermer
|
|
</Button>
|
|
</div>
|
|
<div className="mt-3 flex min-w-0 flex-col gap-2">
|
|
<label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
|
|
Profil
|
|
<select
|
|
aria-label="profil cible"
|
|
value={profileDialog.selectedProfileId}
|
|
disabled={profileDialog.busy}
|
|
className="rounded-md border border-border bg-raised px-2 py-1 text-sm text-content"
|
|
onChange={(event) =>
|
|
setProfileDialog((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
selectedProfileId: event.target.value,
|
|
confirmedReset: false,
|
|
}
|
|
: prev,
|
|
)
|
|
}
|
|
>
|
|
{profileDialog.profiles.map((candidate) => (
|
|
<option key={candidate.id} value={candidate.id}>
|
|
{candidate.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-xs text-content">
|
|
<input
|
|
type="checkbox"
|
|
checked={profileDialog.confirmedReset}
|
|
disabled={profileDialog.busy}
|
|
onChange={(event) =>
|
|
setProfileDialog((prev) =>
|
|
prev ? { ...prev, confirmedReset: event.target.checked } : prev,
|
|
)
|
|
}
|
|
/>
|
|
<span>Je confirme que la session courante sera reset.</span>
|
|
</label>
|
|
{profileDialog.error && (
|
|
<p role="alert" className="text-xs text-danger">
|
|
{profileDialog.error}
|
|
</p>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={profileDialog.busy}
|
|
onClick={() => setProfileDialog(null)}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
disabled={!profileDialog.confirmedReset || profileDialog.busy}
|
|
loading={profileDialog.busy}
|
|
onClick={() => void confirmProfileCommandChange()}
|
|
>
|
|
Reset et changer le profil
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={scrollRef}
|
|
data-testid="custom-agent-chat-scroll"
|
|
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
|
|
>
|
|
<div className="shrink-0">
|
|
<AnnouncementsPreview
|
|
requester={agentId}
|
|
fallbackActive={delegationActive}
|
|
fallbackText={delegationFallbackText}
|
|
/>
|
|
</div>
|
|
{opening && displayTurns.length === 0 ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted">
|
|
<Spinner size={14} />
|
|
<span>
|
|
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
|
</span>
|
|
</div>
|
|
) : displayTurns.length === 0 && hasLinkedConversation ? (
|
|
<p className="m-auto text-xs text-muted">
|
|
Conversation liée — récupération du transcript en cours.
|
|
</p>
|
|
) : displayTurns.length === 0 ? (
|
|
<p className="m-auto text-xs text-muted">
|
|
Envoyez un message pour démarrer la conversation structurée.
|
|
</p>
|
|
) : (
|
|
displayTurns.map((turn, index) => (
|
|
<ChatBubble
|
|
key={index}
|
|
turn={turn}
|
|
requesterName={agentName}
|
|
sessionLabel={sessionLabel}
|
|
expandedProgressKeys={expandedProgressKeys}
|
|
onToggleProgressExpanded={(key) =>
|
|
setExpandedProgressKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
return next;
|
|
})
|
|
}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
data-testid="custom-agent-chat-composer"
|
|
className={cn(
|
|
"flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2",
|
|
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
)}
|
|
>
|
|
{attachments.length > 0 && (
|
|
<div className="flex min-w-0 flex-wrap gap-2">
|
|
{attachments.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex max-w-full items-center gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted"
|
|
>
|
|
{item.previewUrl && (
|
|
<img
|
|
src={item.previewUrl}
|
|
alt=""
|
|
data-testid={`attachment-preview-${item.label}`}
|
|
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
|
|
/>
|
|
)}
|
|
<span className="min-w-0 truncate">Fichier joint: {item.label}</span>
|
|
<button
|
|
type="button"
|
|
aria-label={`retirer ${item.label}`}
|
|
className="shrink-0 text-content"
|
|
onClick={() => removeAttachment(item)}
|
|
>
|
|
Retirer
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{slashMenuOpen && slashCommands.length > 0 && (
|
|
<div
|
|
id={`slash-command-menu-${domId}`}
|
|
role="listbox"
|
|
aria-label="suggestions commandes slash"
|
|
className="max-h-48 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
|
|
>
|
|
{slashCommands.map((command, index) => {
|
|
const available = isSlashCommandAvailable(command);
|
|
const selected = index === slashActiveIndex;
|
|
return (
|
|
<button
|
|
key={command.name}
|
|
type="button"
|
|
id={`slash-command-${domId}-${index}`}
|
|
role="option"
|
|
aria-selected={selected}
|
|
aria-disabled={!available}
|
|
className={cn(
|
|
"flex w-full min-w-0 items-start gap-3 px-2.5 py-1.5 text-left text-xs",
|
|
selected ? "bg-primary/15 text-content" : "text-content",
|
|
!available && "cursor-not-allowed opacity-55",
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => selectSlashCommand(command)}
|
|
>
|
|
<span className="shrink-0 font-semibold">{command.name}</span>
|
|
<span className="min-w-0 flex-1 truncate text-muted">
|
|
{command.shortDescription}
|
|
</span>
|
|
{command.requiresConfirmation && (
|
|
<span className="shrink-0 text-muted">confirm</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className="flex min-w-0 items-end gap-2">
|
|
<div className="relative flex min-w-0 flex-1">
|
|
<textarea
|
|
ref={composerRef}
|
|
aria-label={`message CLI custom ${domId}`}
|
|
aria-controls={slashMenuOpen ? `slash-command-menu-${domId}` : undefined}
|
|
aria-activedescendant={
|
|
slashMenuOpen ? `slash-command-${domId}-${slashActiveIndex}` : undefined
|
|
}
|
|
className={cn(
|
|
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 pr-7 text-sm text-content outline-none",
|
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
rows={2}
|
|
value={draft}
|
|
disabled={!supported || opening || effectiveBusy}
|
|
placeholder="Message à l'agent…"
|
|
style={{ height: clampComposerHeight(composerHeightPx) }}
|
|
onChange={(e) => editDraft(e.target.value)}
|
|
onPaste={(e) => void pasteClipboardFiles(e)}
|
|
onKeyDown={(e) => {
|
|
if (slashMenuOpen && slashCommands.length > 0) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSlashActiveIndex((prev) => (prev + 1) % slashCommands.length);
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSlashActiveIndex(
|
|
(prev) => (prev - 1 + slashCommands.length) % slashCommands.length,
|
|
);
|
|
return;
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setSlashMenuOpen(false);
|
|
return;
|
|
}
|
|
if (e.key === "Enter" || e.key === "Tab") {
|
|
e.preventDefault();
|
|
const command = slashCommands[slashActiveIndex];
|
|
if (command) selectSlashCommand(command);
|
|
return;
|
|
}
|
|
}
|
|
if (handleComposerHistoryKeyDown(e)) return;
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void send();
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label={`redimensionner le composer ${domId}`}
|
|
title="Redimensionner le composer"
|
|
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm border border-border bg-raised text-[9px] leading-3 text-muted hover:text-content focus:border-primary focus:outline-none"
|
|
style={{ cursor: "ns-resize" }}
|
|
onMouseDown={startComposerResize}
|
|
onDoubleClick={(e) => {
|
|
e.preventDefault();
|
|
commitComposerHeight(composerDefaultHeightPx);
|
|
}}
|
|
onKeyDown={handleComposerResizeKeyDown}
|
|
>
|
|
↕
|
|
</button>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="shrink-0 whitespace-nowrap"
|
|
disabled={!supported || opening || effectiveBusy}
|
|
onClick={() => void pickAttachment()}
|
|
>
|
|
Joindre
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={effectiveBusy ? "danger" : undefined}
|
|
className="shrink-0 whitespace-nowrap"
|
|
disabled={
|
|
effectiveBusy
|
|
? !supported || !currentSession || !agent.cancelAgentChat || stopping
|
|
: !canSend
|
|
}
|
|
loading={stopping}
|
|
title={effectiveBusy ? "Arrêter la demande en cours" : undefined}
|
|
aria-label={effectiveBusy ? "Stop" : undefined}
|
|
onClick={() => (effectiveBusy ? void stopCurrentRequest() : void send())}
|
|
>
|
|
{stopping ? "Stopping..." : effectiveBusy ? "Stop" : "Envoyer"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatBubble({
|
|
turn,
|
|
requesterName,
|
|
sessionLabel,
|
|
expandedProgressKeys,
|
|
onToggleProgressExpanded,
|
|
}: {
|
|
turn: ChatTurn;
|
|
requesterName: string;
|
|
sessionLabel: string | null;
|
|
expandedProgressKeys: Set<string>;
|
|
onToggleProgressExpanded: (key: string) => void;
|
|
}) {
|
|
if (turn.role === "restart") {
|
|
return (
|
|
<div
|
|
data-testid="agent-restart-separator"
|
|
className={cn(
|
|
"flex min-w-0 flex-col items-center gap-1 py-1 text-xs",
|
|
turn.failed ? "text-danger" : "text-muted",
|
|
)}
|
|
>
|
|
<span className="whitespace-pre-wrap text-center">{turn.text}</span>
|
|
{turn.message && <span className="max-w-[86%] text-center">{turn.message}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
if (turn.role === "tool") {
|
|
return (
|
|
<div className="flex min-w-0 justify-center">
|
|
<span className="max-w-[80%] truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
|
|
{turn.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (turn.role === "progress") {
|
|
const key = turnProgressKey(turn);
|
|
return (
|
|
<ProgressBubble
|
|
progress={turn.progress}
|
|
requesterName={requesterName}
|
|
progressKey={key}
|
|
expanded={expandedProgressKeys.has(key)}
|
|
onToggleExpanded={() => onToggleProgressExpanded(key)}
|
|
/>
|
|
);
|
|
}
|
|
if (turn.role === "final") {
|
|
return (
|
|
<div className="flex min-w-0 justify-end">
|
|
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
|
|
<ChatMessageMeta
|
|
label="Terminé"
|
|
receivedAtMs={turn.receivedAtMs}
|
|
labelClassName="font-semibold text-success"
|
|
/>
|
|
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (turn.role === "error" || turn.role === "unknown") {
|
|
return (
|
|
<div className="flex min-w-0 justify-center">
|
|
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
|
|
{turn.role === "error" && (
|
|
<ChatMessageMeta
|
|
label="Échec"
|
|
receivedAtMs={turn.receivedAtMs}
|
|
labelClassName="font-semibold text-danger"
|
|
/>
|
|
)}
|
|
{turn.role === "error" && sessionLabel && (
|
|
<p className="mb-1 font-medium text-danger/85">{sessionLabel}</p>
|
|
)}
|
|
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
|
|
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const user = turn.role === "user";
|
|
return (
|
|
<div className="flex min-w-0 justify-start">
|
|
<div
|
|
className={cn(
|
|
"min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content",
|
|
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10",
|
|
)}
|
|
>
|
|
<ChatMessageMeta
|
|
label={user ? "Vous" : requesterName}
|
|
receivedAtMs={turn.receivedAtMs}
|
|
/>
|
|
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
|
{user && turn.attachments && turn.attachments.length > 0 && (
|
|
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
|
|
{turn.attachments.map((attachment) => (
|
|
<p key={attachment} className="truncate">
|
|
Fichier: {attachment}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!user && turn.pending && (
|
|
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
|
|
<Spinner size={12} />
|
|
Réponse en cours
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function normalizeChatReceivedAtMs(receivedAtMs: number | undefined): number | undefined {
|
|
if (!Number.isFinite(receivedAtMs) || receivedAtMs === undefined || receivedAtMs <= 0) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
receivedAtMs >= MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS &&
|
|
receivedAtMs < MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS
|
|
) {
|
|
return receivedAtMs * 1000;
|
|
}
|
|
if (
|
|
receivedAtMs < MIN_REASONABLE_CHAT_TIMESTAMP_MS ||
|
|
receivedAtMs >= MAX_REASONABLE_CHAT_TIMESTAMP_MS
|
|
) {
|
|
return undefined;
|
|
}
|
|
return receivedAtMs;
|
|
}
|
|
|
|
function formatChatReceivedTime(receivedAtMs: number | undefined): string {
|
|
const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs);
|
|
if (!normalizedAtMs) return "";
|
|
try {
|
|
const parts = new Intl.DateTimeFormat(undefined, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hourCycle: "h23",
|
|
}).formatToParts(new Date(normalizedAtMs));
|
|
const hour = parts.find((part) => part.type === "hour")?.value;
|
|
const minute = parts.find((part) => part.type === "minute")?.value;
|
|
return hour && minute ? `${hour}:${minute}` : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function ChatMessageMeta({
|
|
label,
|
|
receivedAtMs,
|
|
labelClassName,
|
|
}: {
|
|
label: string;
|
|
receivedAtMs: number | undefined;
|
|
labelClassName?: string;
|
|
}) {
|
|
const time = formatChatReceivedTime(receivedAtMs);
|
|
const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs);
|
|
const dateTime = time && normalizedAtMs ? new Date(normalizedAtMs).toISOString() : undefined;
|
|
return (
|
|
<div className="mb-1 flex min-w-0 flex-col gap-0.5 text-xs sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
|
|
<span className={cn("min-w-0 truncate font-medium text-muted", labelClassName)}>
|
|
{label}
|
|
</span>
|
|
{time && (
|
|
<time
|
|
dateTime={dateTime}
|
|
className="shrink-0 text-faint"
|
|
>
|
|
{time}
|
|
</time>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function progressTitle(progress: ReplyProgress): string {
|
|
if (isDelegationProgress(progress)) return "Délégation";
|
|
if (progress.kind === "mcp") return "MCP";
|
|
if (progress.kind === "tool") return "Tool";
|
|
if (progress.kind === "message") return "Progress";
|
|
if (progress.kind === "turn") return "Tour";
|
|
return "Activité";
|
|
}
|
|
|
|
function progressSourceLabel(progress: ReplyProgress): string {
|
|
if (progress.source === "ideaLocal") return "IdeA";
|
|
return progress.provider ?? "Provider";
|
|
}
|
|
|
|
function progressStageLabel(stage: ReplyProgressStage): string {
|
|
switch (stage) {
|
|
case "started":
|
|
return "En cours";
|
|
case "delta":
|
|
return "En cours";
|
|
case "completed":
|
|
return "Terminé";
|
|
case "info":
|
|
return "info";
|
|
}
|
|
}
|
|
|
|
function progressTone(progress: ReplyProgress): string {
|
|
if (isDelegationProgress(progress)) {
|
|
return "border-primary/45 bg-primary/10 text-content";
|
|
}
|
|
if (progress.kind === "mcp") {
|
|
return "border-warning/45 bg-warning/10 text-content";
|
|
}
|
|
if (progress.kind === "tool") {
|
|
return "border-border bg-raised/70 text-content";
|
|
}
|
|
return "border-border/80 bg-surface/70 text-muted";
|
|
}
|
|
|
|
function isCenteredToolProgress(progress: ReplyProgress): boolean {
|
|
return (
|
|
progress.kind === "tool" ||
|
|
progress.kind === "mcp" ||
|
|
isDelegationProgress(progress)
|
|
);
|
|
}
|
|
|
|
function isTimelineProgress(progress: ReplyProgress): boolean {
|
|
return progress.kind === "turn";
|
|
}
|
|
|
|
function compactToolName(progress: ReplyProgress): string {
|
|
return progress.toolName ?? progress.label;
|
|
}
|
|
|
|
function previewText(text: string, maxLineLength = 160): string {
|
|
return text
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\r/g, "\n")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.slice(0, 4)
|
|
.map((line) =>
|
|
line.length > maxLineLength
|
|
? `${line.slice(0, Math.max(0, maxLineLength - 3))}...`
|
|
: line,
|
|
)
|
|
.join("\n");
|
|
}
|
|
|
|
function parseKeyValue(text: string, key: string): string | null {
|
|
const match = new RegExp(
|
|
`(?:^|\\s)${key}=([^\\n]+?)(?=\\s\\w+=|$)`,
|
|
"i",
|
|
).exec(text);
|
|
return match?.[1]?.trim().replace(/^["']|["']$/g, "") || null;
|
|
}
|
|
|
|
function delegationSummary(progress: ReplyProgress): {
|
|
requester: string | null;
|
|
target: string | null;
|
|
detail: string | null;
|
|
excerpt: string | null;
|
|
} {
|
|
const text = progress.text ?? "";
|
|
const requester = parseKeyValue(text, "requester");
|
|
const target = parseKeyValue(text, "target");
|
|
const task = parseKeyValue(text, "task");
|
|
const fallback = text && !target && !task ? text : null;
|
|
return {
|
|
requester,
|
|
target,
|
|
detail: task ?? fallback,
|
|
excerpt: task
|
|
? previewText(task, 92)
|
|
: fallback
|
|
? previewText(fallback, 92)
|
|
: null,
|
|
};
|
|
}
|
|
|
|
const fourLineClamp = {
|
|
display: "-webkit-box",
|
|
WebkitBoxOrient: "vertical",
|
|
WebkitLineClamp: 4,
|
|
} as const;
|
|
|
|
function useIsTextTruncated(
|
|
ref: RefObject<HTMLElement>,
|
|
content: string,
|
|
expanded: boolean,
|
|
) {
|
|
const [truncated, setTruncated] = useState(false);
|
|
|
|
useLayoutEffect(() => {
|
|
const element = ref.current;
|
|
if (!element) return;
|
|
|
|
const measure = () => {
|
|
const previousDisplay = element.style.display;
|
|
const previousBoxOrient = element.style.webkitBoxOrient;
|
|
const previousLineClamp = element.style.webkitLineClamp;
|
|
|
|
if (expanded) {
|
|
element.style.display = "-webkit-box";
|
|
element.style.webkitBoxOrient = "vertical";
|
|
element.style.webkitLineClamp = "4";
|
|
}
|
|
|
|
setTruncated(element.scrollHeight > element.clientHeight + 1);
|
|
|
|
if (expanded) {
|
|
element.style.display = previousDisplay;
|
|
element.style.webkitBoxOrient = previousBoxOrient;
|
|
element.style.webkitLineClamp = previousLineClamp;
|
|
}
|
|
};
|
|
|
|
measure();
|
|
const frame = window.requestAnimationFrame(measure);
|
|
window.addEventListener("resize", measure);
|
|
const observer =
|
|
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
observer?.observe(element);
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(frame);
|
|
window.removeEventListener("resize", measure);
|
|
observer?.disconnect();
|
|
};
|
|
}, [content, expanded, ref]);
|
|
|
|
return truncated;
|
|
}
|
|
|
|
function ExpandableProgressDetail({
|
|
content,
|
|
className,
|
|
expanded,
|
|
onToggle,
|
|
}: {
|
|
content: string;
|
|
className?: string;
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const contentId = useId();
|
|
const detailRef = useRef<HTMLParagraphElement>(null);
|
|
const truncated = useIsTextTruncated(detailRef, content, expanded);
|
|
const showToggle = truncated;
|
|
|
|
return (
|
|
<div className="mt-1 min-w-0">
|
|
<p
|
|
id={contentId}
|
|
ref={detailRef}
|
|
data-testid="chat-progress-detail"
|
|
className={cn(
|
|
"overflow-hidden whitespace-pre-wrap break-words text-[11px] text-content",
|
|
className,
|
|
)}
|
|
style={expanded ? undefined : fourLineClamp}
|
|
>
|
|
{content}
|
|
</p>
|
|
{showToggle && (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"mt-1 text-[11px] font-medium text-primary underline-offset-2 hover:underline",
|
|
className?.includes("text-center") && "mx-auto block",
|
|
)}
|
|
aria-expanded={expanded}
|
|
aria-controls={contentId}
|
|
onClick={onToggle}
|
|
>
|
|
{expanded ? "Afficher moins" : "Afficher plus"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TurnMarker({ progress }: { progress: ReplyProgress }) {
|
|
const stage = progressStageLabel(progress.stage);
|
|
const label = progress.label.trim() || "Tour";
|
|
return (
|
|
<div data-testid="chat-turn-marker" className="flex min-w-0 justify-center">
|
|
<div className="flex max-w-[72%] items-center gap-2 overflow-hidden text-[11px] text-muted">
|
|
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
|
|
<span className="shrink-0 rounded-full border border-border bg-surface px-2 py-0.5">
|
|
{stage}
|
|
</span>
|
|
<span className="truncate">{label}</span>
|
|
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToolProgressCard({
|
|
progress,
|
|
requesterName,
|
|
progressKey,
|
|
expanded,
|
|
onToggleExpanded,
|
|
}: {
|
|
progress: ReplyProgress;
|
|
requesterName: string;
|
|
progressKey: string;
|
|
expanded: boolean;
|
|
onToggleExpanded: () => void;
|
|
}) {
|
|
const stage = progressStageLabel(progress.stage);
|
|
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
|
|
const detail = delegation
|
|
? delegation.detail
|
|
: progress.text?.trim() || null;
|
|
const title = delegation
|
|
? delegation.target
|
|
? `${delegation.requester ?? requesterName} ${
|
|
progress.stage === "completed" ? "←" : "→"
|
|
} ${delegation.target}`
|
|
: "Appel inter-agent"
|
|
: progressTitle(progress);
|
|
return (
|
|
<div data-testid="chat-tool-progress" className="flex min-w-0 justify-center">
|
|
<div
|
|
className={cn(
|
|
"min-w-0 max-w-[72%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
|
|
progressTone(progress),
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center justify-center gap-1.5">
|
|
<span className="shrink-0 font-semibold">{title}</span>
|
|
{!delegation && (
|
|
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
|
|
)}
|
|
<span className="shrink-0 text-muted">· {stage}</span>
|
|
</div>
|
|
{delegation?.excerpt && !detail && (
|
|
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
|
|
{delegation.excerpt}
|
|
</p>
|
|
)}
|
|
{delegation && (
|
|
<p className="mt-0.5 truncate text-center text-[11px] text-muted">
|
|
{compactToolName(progress)}
|
|
</p>
|
|
)}
|
|
{(progress.source || progress.nativeEvent || progress.provider) && (
|
|
<div className="mt-0.5 flex min-w-0 flex-wrap justify-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
|
<span>{progressSourceLabel(progress)}</span>
|
|
{progress.nativeEvent && (
|
|
<span className="truncate">event: {progress.nativeEvent}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{detail && (
|
|
<ExpandableProgressDetail
|
|
key={progressKey}
|
|
content={detail}
|
|
className="text-center"
|
|
expanded={expanded}
|
|
onToggle={onToggleExpanded}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProgressBubble({
|
|
progress,
|
|
requesterName,
|
|
progressKey,
|
|
expanded,
|
|
onToggleExpanded,
|
|
}: {
|
|
progress: ReplyProgress;
|
|
requesterName: string;
|
|
progressKey: string;
|
|
expanded: boolean;
|
|
onToggleExpanded: () => void;
|
|
}) {
|
|
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
|
|
if (isCenteredToolProgress(progress)) {
|
|
return (
|
|
<ToolProgressCard
|
|
progress={progress}
|
|
requesterName={requesterName}
|
|
progressKey={progressKey}
|
|
expanded={expanded}
|
|
onToggleExpanded={onToggleExpanded}
|
|
/>
|
|
);
|
|
}
|
|
const title = progressTitle(progress);
|
|
const stage = progressStageLabel(progress.stage);
|
|
const source = progressSourceLabel(progress);
|
|
const detail = progress.text?.trim() || null;
|
|
return (
|
|
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
|
|
<div
|
|
className={cn(
|
|
"min-w-0 max-w-[58%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
|
|
progressTone(progress),
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="shrink-0 font-semibold">{title}</span>
|
|
<span className="min-w-0 truncate text-muted">{progress.label}</span>
|
|
<span className="shrink-0 text-muted">· {stage}</span>
|
|
</div>
|
|
<div className="mt-0.5 flex min-w-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
|
<span>{source}</span>
|
|
{progress.nativeEvent && (
|
|
<span className="truncate">event: {progress.nativeEvent}</span>
|
|
)}
|
|
</div>
|
|
{detail && (
|
|
<ExpandableProgressDetail
|
|
key={progressKey}
|
|
content={detail}
|
|
expanded={expanded}
|
|
onToggle={onToggleExpanded}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|