From 75635bdc8cfbc9d9bac9c1943335e050617170d9 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 11 Aug 2026 20:36:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(chat):=20d=C3=A9duplication=20des=20progres?= =?UTF-8?q?s=20interm=C3=A9diaires=20dans=20la=20timeline=20(#192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les progress « started » d'une même entité s'empilaient en plusieurs turns et n'étaient dédupliqués qu'a posteriori par scan linéaire du tableau de turns, d'où des doublons visibles et un affichage retardé (re-scan par chunk). appendProgress opérait directement sur turns[] (chaque « started » y était poussé puis retrouvé/remplacé par recherche séquentielle). Désormais: - ChatViewState (turns + entityProgressMap): la Map indexée par « entité\0bucket » (started/completed) est l'unique source de vérité pour les progress en cours; - les progress « started » ne deviennent des turns qu'au flush (completeChatViewProgress sur final/error), supprimant la duplication et rendant l'affichage immédiat; - « completed » merge depuis l'entrée running de la Map puis la retire, au lieu de scanner le tableau; - turnsIndicateBusy s'appuie sur la taille de la Map (état vivant) en plus du scan des turns. Tests: déduplication des progress par entité (started répétés → une seule entrée, flush au final), fusion « completed » depuis la Map, état busy reflété par la Map, et rendu non dupliqué sur l'enchaînement started/completed/final. --- .../agents/CustomAgentChatView.test.tsx | 316 +++++++++++++++++ .../features/agents/CustomAgentChatView.tsx | 324 ++++++++++++------ 2 files changed, 540 insertions(+), 100 deletions(-) diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 9d168d09..7c30d29a 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -851,6 +851,322 @@ describe("CustomAgentChatView", () => { ); }); + it("deduplicates identical entity progress chunks", async () => { + let emitChunk: ((chunk: unknown) => void) | null = null; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn( + (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => + new Promise((resolve) => { + emitChunk = (chunk: unknown) => { + onChunk(chunk); + if ((chunk as { kind?: string }).kind === "final") resolve(); + }; + }), + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + fireEvent.change(screen.getByLabelText(/message CLI custom/), { + target: { value: "read ticket" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(emitChunk).not.toBeNull()); + + const chunk = { + kind: "progress" as const, + progress: { + source: "ideaLocal" as const, + kind: "mcp" as const, + stage: "started" as const, + label: "idea_ticket_read", + toolName: "idea_ticket_read", + }, + }; + + act(() => { + emitChunk?.(chunk); + emitChunk?.(chunk); + }); + + expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); + expect(screen.getAllByText("idea_ticket_read")).toHaveLength(1); + }); + + it("updates entity progress deltas in place", async () => { + let emitChunk: ((chunk: unknown) => void) | null = null; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn( + (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => + new Promise((resolve) => { + emitChunk = (chunk: unknown) => { + onChunk(chunk); + if ((chunk as { kind?: string }).kind === "final") resolve(); + }; + }), + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + fireEvent.change(screen.getByLabelText(/message CLI custom/), { + target: { value: "download model" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(emitChunk).not.toBeNull()); + + const progress = (stage: "started" | "delta", text: string) => ({ + kind: "progress" as const, + progress: { + source: "providerNative" as const, + kind: "tool" as const, + stage, + label: "download_model", + toolName: "download_model", + text, + }, + }); + + act(() => { + emitChunk?.(progress("started", "Downloading 10%")); + emitChunk?.(progress("delta", "Downloading 75%")); + }); + + expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); + expect(screen.queryByText("Downloading 10%")).toBeNull(); + expect(screen.getByText("Downloading 75%")).toBeTruthy(); + expect(screen.getByText("Tool").parentElement?.textContent).toContain("running"); + }); + + it("renders progress immediately when the chunk callback fires", async () => { + let emitChunk: ((chunk: unknown) => void) | null = null; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn( + (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => + new Promise((resolve) => { + emitChunk = (chunk: unknown) => { + onChunk(chunk); + if ((chunk as { kind?: string }).kind === "final") resolve(); + }; + }), + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + fireEvent.change(screen.getByLabelText(/message CLI custom/), { + target: { value: "stream progress" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(emitChunk).not.toBeNull()); + + act(() => { + emitChunk?.({ + kind: "progress" as const, + progress: { + source: "providerNative" as const, + kind: "tool" as const, + stage: "started" as const, + label: "stream_repo", + toolName: "stream_repo", + text: "Chunk received", + }, + }); + }); + + expect(screen.getByText("Chunk received")).toBeTruthy(); + expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); + }); + + it("moves completed entity progress from active map to turns", async () => { + let emitChunk: ((chunk: unknown) => void) | null = null; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn( + (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => + new Promise((resolve) => { + emitChunk = (chunk: unknown) => { + onChunk(chunk); + if ((chunk as { kind?: string }).kind === "final") resolve(); + }; + }), + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + fireEvent.change(screen.getByLabelText(/message CLI custom/), { + target: { value: "call tool" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(emitChunk).not.toBeNull()); + + const progress = (stage: "started" | "completed") => ({ + kind: "progress" as const, + progress: { + source: "providerNative" as const, + kind: "tool" as const, + stage, + label: "search_repo", + toolName: "search_repo", + text: stage === "started" ? "Searching..." : "Found results", + }, + }); + + act(() => { + emitChunk?.(progress("started")); + emitChunk?.(progress("completed")); + }); + + expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); + expect(screen.queryByText("Searching...")).toBeNull(); + expect(screen.getByText("Found results")).toBeTruthy(); + expect(screen.getByText("Tool").parentElement?.textContent).toContain("done"); + + act(() => { + emitChunk?.({ kind: "final", content: "done" }); + }); + + await screen.findByText("done"); + expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); + expect(screen.getByText("Found results")).toBeTruthy(); + }); + it("renders running progress without spinner and marks it done on final", async () => { let emitChunk: ((chunk: unknown) => void) | null = null; const agent = { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 35fb17c7..3c5a3c2e 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -55,6 +55,7 @@ type ChatTurn = interface CachedChatView { turns: ChatTurn[]; + entityProgressMap: Map; chunkCount: number; } @@ -262,42 +263,57 @@ function progressDisplayKey(progress: ReplyProgress): string { return progressKey(progress); } -function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] { +interface ChatViewState { + turns: ChatTurn[]; + entityProgressMap: Map; +} + +function emptyChatViewState(): ChatViewState { + return { turns: [], entityProgressMap: new Map() }; +} + +function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState { const entity = progressEntityKey(progress); - if (entity && progress.stage === "completed") { - let runningIndex = -1; - for (let index = turns.length - 1; index >= 0; index -= 1) { - const turn = turns[index]; - if ( - turn.role === "progress" && - progressEntityKey(turn.progress) === entity && - (turn.progress.stage === "started" || turn.progress.stage === "delta") - ) { - runningIndex = index; - break; - } + if (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 (runningIndex >= 0) { - const next = [...turns]; - const current = next[runningIndex]; - if (current.role === "progress") { - next[runningIndex] = { - role: "progress", - progress: mergeCompletedProgress(current.progress, progress), - }; + 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 }; } - return next; + const completed = running ? mergeCompletedProgress(running, progress) : progress; + return { + turns: [...state.turns, { role: "progress", progress: completed }], + entityProgressMap: nextMap, + }; } } const key = progressDisplayKey(progress); if ( - turns.some( + state.turns.some( (turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key, ) ) { - return turns; + return state; } - return [...turns, { role: "progress", progress }]; + return { + turns: [...state.turns, { role: "progress", progress }], + entityProgressMap: state.entityProgressMap, + }; } function mergeCompletedProgress( @@ -352,13 +368,25 @@ 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(), + }; +} + function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean { return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE; } -function turnsIndicateBusy(turns: ChatTurn[]): boolean { - for (let index = turns.length - 1; index >= 0; index -= 1) { - const turn = turns[index]; +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; if (turn.role === "agent" && turn.pending) return true; if ( @@ -490,9 +518,13 @@ function formatSlashCommandHelp(commands: SlashCommand[]): string { .join("\n"); } -function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { +function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { + const { turns } = state; if (!isReplyRecord(raw)) { - return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; + return { + turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], + entityProgressMap: state.entityProgressMap, + }; } const last = turns[turns.length - 1]; if ( @@ -501,44 +533,63 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { raw.kind !== "UserPrompt" ) { if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") { - return foldChunk(turns.slice(0, -1), raw); + return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw); } - return turns; + return state; } switch (raw.kind) { case "textDelta": - return appendAgentDelta(turns, String(raw.text ?? "")); + return { + turns: appendAgentDelta(turns, String(raw.text ?? "")), + entityProgressMap: state.entityProgressMap, + }; case "toolActivity": - return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }]; + return { + turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }], + entityProgressMap: state.entityProgressMap, + }; case "progress": { const progress = normalizeProgress(raw.progress); - if (!progress) return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; - return appendProgress(turns, progress); + if (!progress) { + return { + turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], + entityProgressMap: state.entityProgressMap, + }; + } + return appendProgress(state, progress); } case "final": { const content = String(raw.content ?? ""); - const next = completeRunningProgress(turns); + 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 }); - return next; + return { turns: next, entityProgressMap: completedState.entityProgressMap }; } case "error": { - const next = completeRunningProgress(turns); + 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") }); - return next; + return { turns: next, entityProgressMap: completedState.entityProgressMap }; } case "userPrompt": case "UserPrompt": - return appendUserPrompt( - turns, - String(raw.text ?? raw.prompt ?? ""), - attachmentLabels(raw.attachments), - ); + return { + turns: appendUserPrompt( + turns, + String(raw.text ?? raw.prompt ?? ""), + attachmentLabels(raw.attachments), + ), + entityProgressMap: state.entityProgressMap, + }; default: - return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; + return { + turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], + entityProgressMap: state.entityProgressMap, + }; } } @@ -560,6 +611,7 @@ export function CustomAgentChatView({ const [turns, setTurns] = useState( () => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []), ); + const [entityProgressMap, setEntityProgressMap] = useState>(new Map()); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); const [draft, setDraft] = useState(""); @@ -575,6 +627,8 @@ export function CustomAgentChatView({ const [error, setError] = useState(null); const scrollRef = useRef(null); const composerRef = useRef(null); + const turnsRef = useRef(turns); + const entityProgressMapRef = useRef(entityProgressMap); const sessionRef = useRef(sessionId); sessionRef.current = currentSession; const scrollbackChunkCountRef = useRef( @@ -597,6 +651,25 @@ export function CustomAgentChatView({ const onConversationIdRef = useRef(onConversationId); onConversationIdRef.current = onConversationId; + const applyChatViewState = useCallback((next: ChatViewState) => { + turnsRef.current = next.turns; + entityProgressMapRef.current = next.entityProgressMap; + setTurns(next.turns); + setEntityProgressMap(next.entityProgressMap); + }, []); + + const updateChatViewState = useCallback( + (updater: (prev: ChatViewState) => ChatViewState) => { + applyChatViewState( + updater({ + turns: turnsRef.current, + entityProgressMap: entityProgressMapRef.current, + }), + ); + }, + [applyChatViewState], + ); + const supported = Boolean( profile.structuredAdapter && agent.launchAgentChat && @@ -610,6 +683,16 @@ export function CustomAgentChatView({ () => (layoutId ? { projectId, layoutId, nodeId } : null), [layoutId, nodeId, projectId], ); + const displayTurns = useMemo( + () => [ + ...turns, + ...Array.from(entityProgressMap.values()).map((progress) => ({ + role: "progress" as const, + progress, + })), + ], + [entityProgressMap, turns], + ); const clearPersistedDraft = useCallback(async () => { if (!draftScope) return; @@ -645,19 +728,23 @@ export function CustomAgentChatView({ }; }, []); - const receive = useCallback((chunk: ReplyChunk) => { - scrollbackChunkCountRef.current += 1; - setTurns((prev) => foldChunk(prev, chunk)); - if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); - }, []); + const receive = useCallback( + (chunk: ReplyChunk) => { + scrollbackChunkCountRef.current += 1; + updateChatViewState((prev) => foldChunk(prev, chunk)); + if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); + }, + [updateChatViewState], + ); useEffect(() => { if (!currentSession) return; chatSessionViewCache.set(currentSession, { turns, + entityProgressMap, chunkCount: scrollbackChunkCountRef.current, }); - }, [currentSession, turns]); + }, [currentSession, entityProgressMap, turns]); useEffect(() => { draftSaveSeqRef.current += 1; @@ -807,13 +894,13 @@ export function CustomAgentChatView({ setCurrentSession(reattached.sessionId); if (options.applyScrollback) { scrollbackChunkCountRef.current = reattached.scrollback.length; - const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]); - setTurns(replayed); + const replayed = reattached.scrollback.reduce(foldChunk, emptyChatViewState()); + applyChatViewState(replayed); setBusy(turnsIndicateBusy(replayed)); } return reattached.sessionId; }, - [agent, receive], + [agent, applyChatViewState, receive], ); const recoverStructuredSession = useCallback( @@ -920,7 +1007,7 @@ export function CustomAgentChatView({ useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; - }, [turns]); + }, [displayTurns]); useEffect(() => { if (selfEmittedSessionIdRef.current === sessionId) { @@ -953,7 +1040,10 @@ export function CustomAgentChatView({ const cached = chatSessionViewCache.get(externalSessionId); if (cached) { scrollbackChunkCountRef.current = cached.chunkCount; - setTurns(cached.turns); + applyChatViewState({ + turns: cached.turns, + entityProgressMap: cached.entityProgressMap ?? new Map(), + }); } try { await reattachStructuredSession(externalSessionId, { applyScrollback: true }); @@ -1014,6 +1104,7 @@ export function CustomAgentChatView({ reattachStructuredSession, recoverStructuredSession, publishSessionId, + applyChatViewState, ]); const canSend = useMemo( @@ -1066,7 +1157,7 @@ export function CustomAgentChatView({ 24, 80, ); - setTurns([]); + applyChatViewState(emptyChatViewState()); setCurrentSession(null); publishSessionId(null); onConversationIdRef.current(null); @@ -1093,7 +1184,10 @@ export function CustomAgentChatView({ }); } - setTurns([{ role: "tool", label: `Profil changé vers ${selected.name}.` }]); + applyChatViewState({ + turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }], + entityProgressMap: new Map(), + }); setProfileDialog(null); } catch (e) { setProfileDialog((prev) => @@ -1125,14 +1219,14 @@ export function CustomAgentChatView({ }); if (result.effect.kind === "help") { const helpText = formatSlashCommandHelp(result.effect.commands); - setTurns((prev) => [ - ...prev, - { role: "agent", text: helpText }, - ]); + updateChatViewState((prev) => ({ + turns: [...prev.turns, { role: "agent", text: helpText }], + entityProgressMap: prev.entityProgressMap, + })); return; } if (result.effect.kind === "cleanConversation") { - setTurns([]); + applyChatViewState(emptyChatViewState()); return; } if (result.effect.kind === "profileSwitch") { @@ -1145,16 +1239,22 @@ export function CustomAgentChatView({ result.effect.commandId, ...result.effect.arguments, ); - setTurns((prev) => [ - ...prev, - { role: "tool", label: `Commande plugin ${result.command.name} exécutée.` }, - ]); + updateChatViewState((prev) => ({ + turns: [ + ...prev.turns, + { role: "tool", label: `Commande plugin ${result.command.name} exécutée.` }, + ], + entityProgressMap: prev.entityProgressMap, + })); return; } } catch (e) { const message = `Commande ${invocation.name} échouée: ${describe(e)}`; setError(message); - setTurns((prev) => [...prev, { role: "error", text: message }]); + updateChatViewState((prev) => ({ + turns: [...prev.turns, { role: "error", text: message }], + entityProgressMap: prev.entityProgressMap, + })); } } @@ -1233,14 +1333,17 @@ export function CustomAgentChatView({ void clearPersistedDraft(); setBusy(true); setError(null); - setTurns((prev) => [ - ...prev, - { - role: "user", - text: displayText, - ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), - }, - ]); + updateChatViewState((prev) => ({ + turns: [ + ...prev.turns, + { + role: "user", + text: displayText, + ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), + }, + ], + entityProgressMap: prev.entityProgressMap, + })); const sendTurn = (sid: string) => attachmentInputs.length > 0 ? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs }) @@ -1267,13 +1370,19 @@ export function CustomAgentChatView({ } catch (recoveryError) { setBusy(false); setError(describe(recoveryError)); - setTurns((prev) => [...prev, { role: "error", text: describe(recoveryError) }]); + updateChatViewState((prev) => ({ + turns: [...prev.turns, { role: "error", text: describe(recoveryError) }], + entityProgressMap: prev.entityProgressMap, + })); return; } } setBusy(false); setError(describe(e)); - setTurns((prev) => [...prev, { role: "error", text: describe(e) }]); + updateChatViewState((prev) => ({ + turns: [...prev.turns, { role: "error", text: describe(e) }], + entityProgressMap: prev.entityProgressMap, + })); } } @@ -1288,33 +1397,48 @@ export function CustomAgentChatView({ setOpening(false); setCurrentSession(null); publishSessionId(null); - setTurns((prev) => [ - ...completeRunningProgress(prev), - { - role: "tool", - label: - stopped.kind === "structured" - ? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés." - : "Agent arrêté.", - }, - ]); + updateChatViewState((prev) => { + const completed = completeChatViewProgress(prev); + return { + turns: [ + ...completed.turns, + { + role: "tool", + label: + stopped.kind === "structured" + ? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés." + : "Agent arrêté.", + }, + ], + entityProgressMap: completed.entityProgressMap, + }; + }); } catch (e) { if (isNotFound(e)) { setBusy(false); setOpening(false); setCurrentSession(null); publishSessionId(null); - setTurns((prev) => [ - ...completeRunningProgress(prev), - { role: "tool", label: "Session déjà arrêtée." }, - ]); + updateChatViewState((prev) => { + const completed = completeChatViewProgress(prev); + return { + turns: [ + ...completed.turns, + { role: "tool", label: "Session déjà arrêtée." }, + ], + entityProgressMap: completed.entityProgressMap, + }; + }); return; } setError(describe(e)); - setTurns((prev) => [ - ...prev, - { role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` }, - ]); + updateChatViewState((prev) => ({ + turns: [ + ...prev.turns, + { role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` }, + ], + entityProgressMap: prev.entityProgressMap, + })); } finally { setStopping(false); } @@ -1449,19 +1573,19 @@ export function CustomAgentChatView({ data-testid="custom-agent-chat-scroll" className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3" > - {opening && turns.length === 0 ? ( + {opening && displayTurns.length === 0 ? (
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
- ) : turns.length === 0 ? ( + ) : displayTurns.length === 0 ? (

Envoyez un message pour démarrer la conversation structurée.

) : ( - turns.map((turn, index) => ( + displayTurns.map((turn, index) => ( )) )}