feat(chat): resize vertical du composer avec poignée haut-droit (modèle carnet ticket) — #193
This commit is contained in:
@ -13,11 +13,12 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
ChatPromptAttachment,
|
||||
GatewayError,
|
||||
ReplyChunk,
|
||||
ReplyProgress,
|
||||
@ -46,12 +47,15 @@ export interface CustomAgentChatViewProps {
|
||||
nodeId: string;
|
||||
sessionId: string | null;
|
||||
conversationId: string | null;
|
||||
composerHeightPx?: number;
|
||||
composerDefaultHeightPx?: number;
|
||||
onComposerHeightPxChange?: (heightPx: number) => void;
|
||||
onSessionId: (sessionId: string | null) => void;
|
||||
onConversationId: (conversationId: string | null) => void;
|
||||
}
|
||||
|
||||
type ChatTurn =
|
||||
| { role: "user"; text: string; attachments?: ChatPromptAttachment[] }
|
||||
| { role: "user"; text: string; attachments?: string[] }
|
||||
| { role: "agent"; text: string; pending?: boolean }
|
||||
| { role: "tool"; label: string }
|
||||
| { role: "progress"; progress: ReplyProgress; settled?: boolean }
|
||||
@ -61,12 +65,14 @@ type ChatTurn =
|
||||
|
||||
interface CachedChatView {
|
||||
turns: ChatTurn[];
|
||||
entityProgressMap: Map<string, ReplyProgress>;
|
||||
progressIndex: Map<string, ProgressIndexEntry>;
|
||||
chunkCount: number;
|
||||
}
|
||||
|
||||
const chatSessionViewCache = 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;
|
||||
|
||||
export function __clearCustomAgentChatViewCacheForTests() {
|
||||
chatSessionViewCache.clear();
|
||||
@ -78,6 +84,12 @@ interface AttachmentDraft {
|
||||
input: ChatAttachmentInput;
|
||||
previewUrl?: string;
|
||||
dedupeKey?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface ProgressIndexEntry {
|
||||
firstOccurredAtMs: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
interface ProfileCommandDialogState {
|
||||
@ -159,62 +171,10 @@ function attachmentLabels(value: unknown): string[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function isImageFilename(filename: string): boolean {
|
||||
return /\.(apng|avif|gif|jpe?g|png|svg|webp)$/i.test(filename.trim());
|
||||
}
|
||||
|
||||
function fallbackAttachmentRefs(labels: string[]): ChatPromptAttachment[] {
|
||||
let imageCount = 0;
|
||||
let fileCount = 0;
|
||||
return labels.map((filename) => {
|
||||
const image = isImageFilename(filename);
|
||||
const reference = image ? `[image ${++imageCount}]` : `[file ${++fileCount}]`;
|
||||
return { reference, filename };
|
||||
});
|
||||
}
|
||||
|
||||
function attachmentRefs(value: unknown, labels: string[] = []): ChatPromptAttachment[] {
|
||||
if (Array.isArray(value)) {
|
||||
const refs = value
|
||||
.map((item): ChatPromptAttachment | null => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const record = item as Record<string, unknown>;
|
||||
const reference = optionalString(record.reference);
|
||||
const filename = optionalString(record.filename);
|
||||
if (!reference || !filename) return null;
|
||||
return {
|
||||
reference,
|
||||
filename,
|
||||
mime: optionalString(record.mime),
|
||||
sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : undefined,
|
||||
readablePath: optionalString(record.readablePath),
|
||||
};
|
||||
})
|
||||
.filter((item): item is ChatPromptAttachment => Boolean(item));
|
||||
if (refs.length > 0) return refs;
|
||||
}
|
||||
return fallbackAttachmentRefs(labels);
|
||||
}
|
||||
|
||||
function attachmentRefsFromDrafts(attachments: AttachmentDraft[]): ChatPromptAttachment[] {
|
||||
let imageCount = 0;
|
||||
let fileCount = 0;
|
||||
return attachments.map((attachment) => {
|
||||
const mime = attachment.input.mime ?? "";
|
||||
const image = mime.startsWith("image/") || (!mime && isImageFilename(attachment.label));
|
||||
const reference = image ? `[image ${++imageCount}]` : `[file ${++fileCount}]`;
|
||||
return {
|
||||
reference,
|
||||
filename: attachment.label,
|
||||
mime: attachment.input.mime,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function appendUserPrompt(
|
||||
turns: ChatTurn[],
|
||||
text: string,
|
||||
attachments: ChatPromptAttachment[] = [],
|
||||
attachments: string[] = [],
|
||||
): ChatTurn[] {
|
||||
const last = turns[turns.length - 1];
|
||||
if (
|
||||
@ -268,6 +228,10 @@ function normalizeProgress(raw: unknown): ReplyProgress | null {
|
||||
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",
|
||||
@ -281,7 +245,6 @@ function normalizeProgress(raw: unknown): ReplyProgress | null {
|
||||
|
||||
function progressKey(progress: ReplyProgress): string {
|
||||
return [
|
||||
progress.progressId ?? "",
|
||||
progress.source,
|
||||
progress.kind,
|
||||
progress.stage,
|
||||
@ -291,6 +254,14 @@ function progressKey(progress: ReplyProgress): string {
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
function progressUpsertKey(progress: ReplyProgress): string {
|
||||
const progressId = progress.progressId?.trim();
|
||||
if (progressId) return progressId;
|
||||
const entity = progressEntityKey(progress);
|
||||
if (entity) return `legacy-entity:${entity}`;
|
||||
return `legacy:${progressDisplayKey(progress)}`;
|
||||
}
|
||||
|
||||
function normalizedProgressToolName(progress: ReplyProgress): string | null {
|
||||
const name = progress.toolName ?? progress.label;
|
||||
const normalized = name.trim().toLowerCase();
|
||||
@ -303,7 +274,6 @@ function isDelegationProgress(progress: ReplyProgress): boolean {
|
||||
}
|
||||
|
||||
function progressEntityKey(progress: ReplyProgress): string | null {
|
||||
if (progress.progressId) return `id:${progress.progressId}`;
|
||||
const name = normalizedProgressToolName(progress);
|
||||
if (!name) return null;
|
||||
if (isDelegationProgress(progress)) return `delegation:${name}`;
|
||||
@ -312,7 +282,7 @@ function progressEntityKey(progress: ReplyProgress): string | null {
|
||||
}
|
||||
|
||||
function progressStageBucket(progress: ReplyProgress): string {
|
||||
if (!progressEntityKey(progress)) return progress.stage;
|
||||
if (!isCenteredToolProgress(progress)) return progress.stage;
|
||||
if (progress.stage === "started" || progress.stage === "delta") return "started";
|
||||
if (progress.stage === "completed") return "completed";
|
||||
return "info";
|
||||
@ -326,54 +296,61 @@ function progressDisplayKey(progress: ReplyProgress): string {
|
||||
|
||||
interface ChatViewState {
|
||||
turns: ChatTurn[];
|
||||
entityProgressMap: Map<string, ReplyProgress>;
|
||||
progressIndex: Map<string, ProgressIndexEntry>;
|
||||
}
|
||||
|
||||
function emptyChatViewState(): ChatViewState {
|
||||
return { turns: [], entityProgressMap: new Map() };
|
||||
return { turns: [], progressIndex: new Map() };
|
||||
}
|
||||
|
||||
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
||||
const entity = progressEntityKey(progress);
|
||||
if (entity) {
|
||||
const bucket = progressStageBucket(progress);
|
||||
const key = `${entity}\u0000${bucket}`;
|
||||
if (bucket === "started") {
|
||||
const nextMap = new Map(state.entityProgressMap);
|
||||
nextMap.set(key, progress);
|
||||
return { turns: state.turns, entityProgressMap: nextMap };
|
||||
}
|
||||
if (bucket === "completed") {
|
||||
const runningKey = `${entity}\u0000started`;
|
||||
const running = state.entityProgressMap.get(runningKey);
|
||||
const nextMap = new Map(state.entityProgressMap);
|
||||
nextMap.delete(runningKey);
|
||||
if (
|
||||
!running &&
|
||||
state.turns.some(
|
||||
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
|
||||
)
|
||||
) {
|
||||
return { turns: state.turns, entityProgressMap: nextMap };
|
||||
}
|
||||
const completed = running ? mergeCompletedProgress(running, progress) : progress;
|
||||
return {
|
||||
turns: [...state.turns, { role: "progress", progress: completed }],
|
||||
entityProgressMap: nextMap,
|
||||
};
|
||||
}
|
||||
const key = progressUpsertKey(progress);
|
||||
const existingIndex = state.turns.findIndex(
|
||||
(turn) => turn.role === "progress" && progressUpsertKey(turn.progress) === key,
|
||||
);
|
||||
const existingProgress =
|
||||
existingIndex >= 0 && state.turns[existingIndex].role === "progress"
|
||||
? state.turns[existingIndex].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;
|
||||
nextIndex.set(key, { firstOccurredAtMs, sequence });
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const nextTurns = [...state.turns];
|
||||
nextTurns[existingIndex] = { role: "progress", progress: merged };
|
||||
return { turns: nextTurns, progressIndex: nextIndex };
|
||||
}
|
||||
const key = progressDisplayKey(progress);
|
||||
if (
|
||||
state.turns.some(
|
||||
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
|
||||
)
|
||||
) {
|
||||
return state;
|
||||
|
||||
const insertAt = state.turns.findIndex((turn) => {
|
||||
if (turn.role !== "progress") return false;
|
||||
const candidate = state.progressIndex.get(progressUpsertKey(turn.progress));
|
||||
if (!candidate) return false;
|
||||
if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
|
||||
return candidate.firstOccurredAtMs > firstOccurredAtMs;
|
||||
}
|
||||
return candidate.sequence > sequence;
|
||||
});
|
||||
const nextTurn = { role: "progress" as const, progress: merged };
|
||||
if (insertAt < 0) {
|
||||
return { turns: [...state.turns, nextTurn], progressIndex: nextIndex };
|
||||
}
|
||||
return {
|
||||
turns: [...state.turns, { role: "progress", progress }],
|
||||
entityProgressMap: state.entityProgressMap,
|
||||
turns: [
|
||||
...state.turns.slice(0, insertAt),
|
||||
nextTurn,
|
||||
...state.turns.slice(insertAt),
|
||||
],
|
||||
progressIndex: nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@ -430,13 +407,9 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
|
||||
}
|
||||
|
||||
function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
||||
const activeTurns = Array.from(state.entityProgressMap.values()).map((progress) => ({
|
||||
role: "progress" as const,
|
||||
progress,
|
||||
}));
|
||||
return {
|
||||
turns: completeRunningProgress([...state.turns, ...activeTurns]),
|
||||
entityProgressMap: new Map(),
|
||||
turns: completeRunningProgress(state.turns),
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@ -445,7 +418,6 @@ function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
||||
}
|
||||
|
||||
function turnsIndicateBusy(state: ChatViewState): boolean {
|
||||
if (state.entityProgressMap.size > 0) return true;
|
||||
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;
|
||||
@ -461,6 +433,26 @@ function turnsIndicateBusy(state: ChatViewState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
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";
|
||||
@ -584,7 +576,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
||||
if (!isReplyRecord(raw)) {
|
||||
return {
|
||||
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||
entityProgressMap: state.entityProgressMap,
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
}
|
||||
const last = turns[turns.length - 1];
|
||||
@ -602,19 +594,19 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
||||
case "textDelta":
|
||||
return {
|
||||
turns: appendAgentDelta(turns, String(raw.text ?? "")),
|
||||
entityProgressMap: state.entityProgressMap,
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
case "toolActivity":
|
||||
return {
|
||||
turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }],
|
||||
entityProgressMap: state.entityProgressMap,
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
case "progress": {
|
||||
const progress = normalizeProgress(raw.progress);
|
||||
if (!progress) {
|
||||
return {
|
||||
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||
entityProgressMap: state.entityProgressMap,
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
}
|
||||
return appendProgress(state, progress);
|
||||
@ -626,7 +618,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
||||
const last = next[next.length - 1];
|
||||
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
||||
next.push({ role: "final", text: content });
|
||||
return { turns: next, entityProgressMap: completedState.entityProgressMap };
|
||||
return { turns: next, progressIndex: completedState.progressIndex };
|
||||
}
|
||||
case "error": {
|
||||
const completedState = completeChatViewProgress(state);
|
||||
@ -634,7 +626,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
||||
const last = next[next.length - 1];
|
||||
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
||||
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
|
||||
return { turns: next, entityProgressMap: completedState.entityProgressMap };
|
||||
return { turns: next, progressIndex: completedState.progressIndex };
|
||||
}
|
||||
case "userPrompt":
|
||||
case "UserPrompt":
|
||||
@ -642,14 +634,14 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
||||
turns: appendUserPrompt(
|
||||
turns,
|
||||
String(raw.text ?? raw.prompt ?? ""),
|
||||
attachmentRefs(raw.attachmentRefs, attachmentLabels(raw.attachments)),
|
||||
attachmentLabels(raw.attachments),
|
||||
),
|
||||
entityProgressMap: state.entityProgressMap,
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||
entityProgressMap: state.entityProgressMap,
|
||||
progressIndex: state.progressIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -664,6 +656,9 @@ export function CustomAgentChatView({
|
||||
nodeId,
|
||||
sessionId,
|
||||
conversationId,
|
||||
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
||||
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
||||
onComposerHeightPxChange,
|
||||
onSessionId,
|
||||
onConversationId,
|
||||
}: CustomAgentChatViewProps) {
|
||||
@ -672,7 +667,7 @@ export function CustomAgentChatView({
|
||||
const [turns, setTurns] = useState<ChatTurn[]>(
|
||||
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
||||
);
|
||||
const [entityProgressMap, setEntityProgressMap] = useState<Map<string, ReplyProgress>>(new Map());
|
||||
const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(new Map());
|
||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||
const [draft, setDraft] = useState("");
|
||||
@ -688,8 +683,9 @@ export function CustomAgentChatView({
|
||||
const [error, setError] = useState<string | 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 entityProgressMapRef = useRef(entityProgressMap);
|
||||
const progressIndexRef = useRef(progressIndex);
|
||||
const sessionRef = useRef<string | null>(sessionId);
|
||||
sessionRef.current = currentSession;
|
||||
const scrollbackChunkCountRef = useRef(
|
||||
@ -706,6 +702,9 @@ export function CustomAgentChatView({
|
||||
const lastPersistedDraftRef = useRef("");
|
||||
const draftRef = useRef("");
|
||||
draftRef.current = draft;
|
||||
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);
|
||||
@ -714,11 +713,68 @@ export function CustomAgentChatView({
|
||||
const onConversationIdRef = useRef(onConversationId);
|
||||
onConversationIdRef.current = onConversationId;
|
||||
|
||||
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;
|
||||
entityProgressMapRef.current = next.entityProgressMap;
|
||||
progressIndexRef.current = next.progressIndex;
|
||||
setTurns(next.turns);
|
||||
setEntityProgressMap(next.entityProgressMap);
|
||||
setProgressIndex(next.progressIndex);
|
||||
}, []);
|
||||
|
||||
const updateChatViewState = useCallback(
|
||||
@ -726,7 +782,7 @@ export function CustomAgentChatView({
|
||||
applyChatViewState(
|
||||
updater({
|
||||
turns: turnsRef.current,
|
||||
entityProgressMap: entityProgressMapRef.current,
|
||||
progressIndex: progressIndexRef.current,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@ -746,16 +802,7 @@ export function CustomAgentChatView({
|
||||
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
||||
[layoutId, nodeId, projectId],
|
||||
);
|
||||
const displayTurns = useMemo<ChatTurn[]>(
|
||||
() => [
|
||||
...turns,
|
||||
...Array.from(entityProgressMap.values()).map((progress) => ({
|
||||
role: "progress" as const,
|
||||
progress,
|
||||
})),
|
||||
],
|
||||
[entityProgressMap, turns],
|
||||
);
|
||||
const displayTurns = turns;
|
||||
|
||||
const clearPersistedDraft = useCallback(async () => {
|
||||
if (!draftScope) return;
|
||||
@ -827,10 +874,10 @@ export function CustomAgentChatView({
|
||||
if (!currentSession) return;
|
||||
chatSessionViewCache.set(currentSession, {
|
||||
turns,
|
||||
entityProgressMap,
|
||||
progressIndex,
|
||||
chunkCount: scrollbackChunkCountRef.current,
|
||||
});
|
||||
}, [currentSession, entityProgressMap, turns]);
|
||||
}, [currentSession, progressIndex, turns]);
|
||||
|
||||
useEffect(() => {
|
||||
draftSaveSeqRef.current += 1;
|
||||
@ -1128,7 +1175,7 @@ export function CustomAgentChatView({
|
||||
scrollbackChunkCountRef.current = cached.chunkCount;
|
||||
applyChatViewState({
|
||||
turns: cached.turns,
|
||||
entityProgressMap: cached.entityProgressMap ?? new Map(),
|
||||
progressIndex: cached.progressIndex ?? new Map(),
|
||||
});
|
||||
}
|
||||
try {
|
||||
@ -1272,7 +1319,7 @@ export function CustomAgentChatView({
|
||||
|
||||
applyChatViewState({
|
||||
turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }],
|
||||
entityProgressMap: new Map(),
|
||||
progressIndex: new Map(),
|
||||
});
|
||||
setProfileDialog(null);
|
||||
} catch (e) {
|
||||
@ -1307,7 +1354,7 @@ export function CustomAgentChatView({
|
||||
const helpText = formatSlashCommandHelp(result.effect.commands);
|
||||
updateChatViewState((prev) => ({
|
||||
turns: [...prev.turns, { role: "agent", text: helpText }],
|
||||
entityProgressMap: prev.entityProgressMap,
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@ -1330,7 +1377,7 @@ export function CustomAgentChatView({
|
||||
...prev.turns,
|
||||
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` },
|
||||
],
|
||||
entityProgressMap: prev.entityProgressMap,
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@ -1339,7 +1386,7 @@ export function CustomAgentChatView({
|
||||
setError(message);
|
||||
updateChatViewState((prev) => ({
|
||||
turns: [...prev.turns, { role: "error", text: message }],
|
||||
entityProgressMap: prev.entityProgressMap,
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1358,6 +1405,15 @@ export function CustomAgentChatView({
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
) {
|
||||
@ -1375,26 +1431,42 @@ export function CustomAgentChatView({
|
||||
const nextAttachments = await Promise.all(
|
||||
files.map(clipboardFileToAttachment),
|
||||
);
|
||||
setAttachments((prev) => {
|
||||
const known = new Set(
|
||||
prev
|
||||
.map((attachment) => attachment.dedupeKey)
|
||||
.filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
const unique = nextAttachments.filter((attachment) => {
|
||||
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 };
|
||||
});
|
||||
return unique.length > 0 ? [...prev, ...unique] : prev;
|
||||
});
|
||||
if (filesFromEvent.length > 0 && pastedText) {
|
||||
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) =>
|
||||
prev.slice(0, selectionStart) +
|
||||
pastedText +
|
||||
prev.slice(selectionEnd),
|
||||
insertTextAtSelection(
|
||||
prev,
|
||||
insertionParts.join(" "),
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@ -1412,7 +1484,7 @@ export function CustomAgentChatView({
|
||||
}
|
||||
const outgoingAttachments = attachments;
|
||||
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
||||
const attachmentRefs = attachmentRefsFromDrafts(outgoingAttachments);
|
||||
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
||||
const displayText = text || "Pièce jointe";
|
||||
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
|
||||
activeInteractionIdRef.current = interactionId;
|
||||
@ -1424,6 +1496,7 @@ export function CustomAgentChatView({
|
||||
};
|
||||
setDraft("");
|
||||
setAttachments([]);
|
||||
imageTokenSeqRef.current = 0;
|
||||
void clearPersistedDraft();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@ -1433,10 +1506,10 @@ export function CustomAgentChatView({
|
||||
{
|
||||
role: "user",
|
||||
text: displayText,
|
||||
...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
|
||||
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
||||
},
|
||||
],
|
||||
entityProgressMap: prev.entityProgressMap,
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
const sendTurn = (sid: string) =>
|
||||
attachmentInputs.length > 0
|
||||
@ -1469,7 +1542,7 @@ export function CustomAgentChatView({
|
||||
setError(describe(recoveryError));
|
||||
updateChatViewState((prev) => ({
|
||||
turns: [...prev.turns, { role: "error", text: describe(recoveryError) }],
|
||||
entityProgressMap: prev.entityProgressMap,
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@ -1479,7 +1552,7 @@ export function CustomAgentChatView({
|
||||
setError(describe(e));
|
||||
updateChatViewState((prev) => ({
|
||||
turns: [...prev.turns, { role: "error", text: describe(e) }],
|
||||
entityProgressMap: prev.entityProgressMap,
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1512,7 +1585,7 @@ export function CustomAgentChatView({
|
||||
: "Agent arrêté.",
|
||||
},
|
||||
],
|
||||
entityProgressMap: completed.entityProgressMap,
|
||||
progressIndex: completed.progressIndex,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
@ -1532,7 +1605,7 @@ export function CustomAgentChatView({
|
||||
...completed.turns,
|
||||
{ role: "tool", label: "Session déjà arrêtée." },
|
||||
],
|
||||
entityProgressMap: completed.entityProgressMap,
|
||||
progressIndex: completed.progressIndex,
|
||||
};
|
||||
});
|
||||
return;
|
||||
@ -1543,7 +1616,7 @@ export function CustomAgentChatView({
|
||||
...prev.turns,
|
||||
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` },
|
||||
],
|
||||
entityProgressMap: prev.entityProgressMap,
|
||||
progressIndex: prev.progressIndex,
|
||||
}));
|
||||
} finally {
|
||||
setStopping(false);
|
||||
@ -1552,6 +1625,7 @@ export function CustomAgentChatView({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chatRootRef}
|
||||
data-testid="custom-agent-chat-view"
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
|
||||
>
|
||||
@ -1721,11 +1795,7 @@ export function CustomAgentChatView({
|
||||
type="button"
|
||||
aria-label={`retirer ${item.label}`}
|
||||
className="shrink-0 text-content"
|
||||
onClick={() =>
|
||||
setAttachments((prev) =>
|
||||
prev.filter((candidate) => candidate.id !== item.id),
|
||||
)
|
||||
}
|
||||
onClick={() => removeAttachment(item)}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
@ -1772,55 +1842,73 @@ export function CustomAgentChatView({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-end gap-2">
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
aria-label={`message CLI custom ${nodeId}`}
|
||||
aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined}
|
||||
aria-activedescendant={
|
||||
slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined
|
||||
}
|
||||
className={cn(
|
||||
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
rows={2}
|
||||
value={draft}
|
||||
disabled={!supported || opening || busy}
|
||||
placeholder="Message à l'agent…"
|
||||
onChange={(e) => editDraft(e.target.value)}
|
||||
onPaste={(e) => void pasteClipboardFiles(e)}
|
||||
onKeyDown={(e) => {
|
||||
if (slashMenuOpen && slashCommands.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSlashActiveIndex((prev) => (prev + 1) % slashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSlashActiveIndex(
|
||||
(prev) => (prev - 1 + slashCommands.length) % slashCommands.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setSlashMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const command = slashCommands[slashActiveIndex];
|
||||
if (command) selectSlashCommand(command);
|
||||
return;
|
||||
}
|
||||
<div className="relative flex min-w-0 flex-1">
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
aria-label={`message CLI custom ${nodeId}`}
|
||||
aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined}
|
||||
aria-activedescendant={
|
||||
slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
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 || busy}
|
||||
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 (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`redimensionner le composer ${nodeId}`}
|
||||
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();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
commitComposerHeight(composerDefaultHeightPx);
|
||||
}}
|
||||
onKeyDown={handleComposerResizeKeyDown}
|
||||
>
|
||||
↕
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@ -1888,6 +1976,9 @@ function ChatBubble({
|
||||
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" && (
|
||||
<div className="mb-1 font-semibold">Échec</div>
|
||||
)}
|
||||
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
|
||||
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
|
||||
</div>
|
||||
@ -1908,8 +1999,8 @@ function ChatBubble({
|
||||
{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.reference}-${attachment.filename}`} className="truncate">
|
||||
{attachment.reference} {attachment.filename}
|
||||
<p key={attachment} className="truncate">
|
||||
Fichier: {attachment}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@ -1942,11 +2033,11 @@ function progressSourceLabel(progress: ReplyProgress): string {
|
||||
function progressStageLabel(stage: ReplyProgressStage): string {
|
||||
switch (stage) {
|
||||
case "started":
|
||||
return "started";
|
||||
return "En cours";
|
||||
case "delta":
|
||||
return "running";
|
||||
return "En cours";
|
||||
case "completed":
|
||||
return "done";
|
||||
return "Terminé";
|
||||
case "info":
|
||||
return "info";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user