merge(chat): déduplication des progress intermédiaires dans la timeline (#192, QA verte)

This commit is contained in:
2026-08-11 20:37:37 +02:00
2 changed files with 540 additions and 100 deletions

View File

@ -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<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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 = {

View File

@ -55,6 +55,7 @@ type ChatTurn =
interface CachedChatView {
turns: ChatTurn[];
entityProgressMap: Map<string, ReplyProgress>;
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<string, ReplyProgress>;
}
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<ChatTurn[]>(
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
);
const [entityProgressMap, setEntityProgressMap] = useState<Map<string, ReplyProgress>>(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<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const turnsRef = useRef(turns);
const entityProgressMapRef = useRef(entityProgressMap);
const sessionRef = useRef<string | null>(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<ChatTurn[]>(
() => [
...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 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
</span>
</div>
) : turns.length === 0 ? (
) : displayTurns.length === 0 ? (
<p className="m-auto text-xs text-muted">
Envoyez un message pour démarrer la conversation structurée.
</p>
) : (
turns.map((turn, index) => (
displayTurns.map((turn, index) => (
<ChatBubble key={index} turn={turn} requesterName={agentName} />
))
)}