merge(web): heure de réception des messages custom CLI — #210 (QA verte)

This commit is contained in:
2026-08-15 09:49:52 +02:00
2 changed files with 308 additions and 31 deletions

View File

@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useState } from "react"; import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
@ -71,6 +71,17 @@ function deferred<T>() {
return { promise, resolve }; return { promise, resolve };
} }
function expectedChatTime(ms: number): string {
const parts = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(ms));
const hour = parts.find((part) => part.type === "hour")?.value;
const minute = parts.find((part) => part.type === "minute")?.value;
return `${hour}:${minute}`;
}
describe("CustomAgentChatView", () => { describe("CustomAgentChatView", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -122,6 +133,161 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy(); expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
}); });
it("renders local reception time in message meta without changing streamed turn grouping", async () => {
const userReceivedAtMs = new Date("2026-08-15T13:05:00.000Z").getTime();
const firstDeltaReceivedAtMs = new Date("2026-08-15T13:06:00.000Z").getTime();
const finalChunkReceivedAtMs = new Date("2026-08-15T13:07:00.000Z").getTime();
let nowMs = userReceivedAtMs;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => {
nowMs = firstDeltaReceivedAtMs;
onChunk({ kind: "textDelta", text: "Streaming reply" });
nowMs = finalChunkReceivedAtMs;
onChunk({ kind: "final", content: "Completed reply" });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
try {
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: "Initial prompt" },
});
nowMs = userReceivedAtMs;
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
const userBubble = (await screen.findByText("Initial prompt")).closest("div");
const agentBubble = screen.getByText("Streaming reply").closest("div");
const finalBubble = screen.getByText("Completed reply").closest("div");
expect(userBubble).toBeTruthy();
expect(agentBubble).toBeTruthy();
expect(finalBubble).toBeTruthy();
expect(within(userBubble!).getByText(expectedChatTime(userReceivedAtMs))).toBeTruthy();
expect(
within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
).toBeTruthy();
expect(
within(finalBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
).toBeTruthy();
expect(screen.getByText("Streaming reply")).toBeTruthy();
expect(screen.getByText("Completed reply")).toBeTruthy();
expect(screen.queryByText(expectedChatTime(finalChunkReceivedAtMs))).toBeNull();
} finally {
nowSpy.mockRestore();
}
});
it("keeps a single reception time on error bubbles using the first streamed chunk time", async () => {
const userReceivedAtMs = new Date("2026-08-15T13:05:00.000Z").getTime();
const firstDeltaReceivedAtMs = new Date("2026-08-15T13:06:00.000Z").getTime();
const errorReceivedAtMs = new Date("2026-08-15T13:07:00.000Z").getTime();
let nowMs = userReceivedAtMs;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => {
nowMs = firstDeltaReceivedAtMs;
onChunk({ kind: "textDelta", text: "Streaming reply" });
nowMs = errorReceivedAtMs;
onChunk({ kind: "error", message: "Broken reply" });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
try {
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: "Initial prompt" },
});
nowMs = userReceivedAtMs;
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
const agentBubble = (await screen.findByText("Streaming reply")).closest("div");
const errorBubble = screen.getByText("Broken reply").closest("div");
expect(agentBubble).toBeTruthy();
expect(errorBubble).toBeTruthy();
expect(
within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
).toBeTruthy();
expect(
within(errorBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
).toBeTruthy();
expect(screen.queryByText(expectedChatTime(errorReceivedAtMs))).toBeNull();
} finally {
nowSpy.mockRestore();
}
});
it("resizes the composer vertically with mouse, keyboard, and double-click reset", async () => { it("resizes the composer vertically with mouse, keyboard, and double-click reset", async () => {
const rectSpy = vi const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect") .spyOn(HTMLElement.prototype, "getBoundingClientRect")
@ -607,9 +773,7 @@ describe("CustomAgentChatView", () => {
await screen.findByText("done"); await screen.findByText("done");
expect(hasProjectCliInteraction("project-1")).toBe(false); expect(hasProjectCliInteraction("project-1")).toBe(false);
expect(screen.getAllByText("hello agent")).toHaveLength(1); expect(screen.getAllByText("hello agent")).toHaveLength(1);
expect(screen.getByText("Terminé").parentElement?.parentElement?.className).toContain( expect(screen.getByText("Terminé").closest(".justify-end")).toBeTruthy();
"justify-end",
);
}); });
it("renders canonical progress chunks as live agent, MCP, and delegation activity", async () => { it("renders canonical progress chunks as live agent, MCP, and delegation activity", async () => {

View File

@ -55,13 +55,13 @@ export interface CustomAgentChatViewProps {
} }
type ChatTurn = type ChatTurn =
| { role: "user"; text: string; attachments?: string[] } | { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number }
| { role: "agent"; text: string; pending?: boolean } | { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number }
| { role: "tool"; label: string } | { role: "tool"; label: string; receivedAtMs?: number }
| { role: "progress"; progress: ReplyProgress; settled?: boolean } | { role: "progress"; progress: ReplyProgress; settled?: boolean }
| { role: "final"; text: string } | { role: "final"; text: string; receivedAtMs?: number }
| { role: "error"; text: string } | { role: "error"; text: string; receivedAtMs?: number }
| { role: "unknown"; text: string }; | { role: "unknown"; text: string; receivedAtMs?: number };
interface CachedChatView { interface CachedChatView {
turns: ChatTurn[]; turns: ChatTurn[];
@ -150,7 +150,11 @@ function isReplyRecord(chunk: unknown): chunk is Record<string, unknown> {
return Boolean(chunk && typeof chunk === "object" && "kind" in chunk); return Boolean(chunk && typeof chunk === "object" && "kind" in chunk);
} }
function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] { function appendAgentDelta(
turns: ChatTurn[],
text: string,
receivedAtMs: number,
): ChatTurn[] {
const next = [...turns]; const next = [...turns];
const last = next[next.length - 1]; const last = next[next.length - 1];
if (last?.role === "agent") { if (last?.role === "agent") {
@ -158,10 +162,11 @@ function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] {
role: "agent", role: "agent",
text: last.text + text, text: last.text + text,
pending: true, pending: true,
receivedAtMs: last.receivedAtMs ?? receivedAtMs,
}; };
return next; return next;
} }
next.push({ role: "agent", text, pending: true }); next.push({ role: "agent", text, pending: true, receivedAtMs });
return next; return next;
} }
@ -175,6 +180,7 @@ function appendUserPrompt(
turns: ChatTurn[], turns: ChatTurn[],
text: string, text: string,
attachments: string[] = [], attachments: string[] = [],
receivedAtMs = Date.now(),
): ChatTurn[] { ): ChatTurn[] {
const last = turns[turns.length - 1]; const last = turns[turns.length - 1];
if ( if (
@ -189,6 +195,7 @@ function appendUserPrompt(
{ {
role: "user", role: "user",
text, text,
receivedAtMs,
...(attachments.length > 0 ? { attachments } : {}), ...(attachments.length > 0 ? { attachments } : {}),
}, },
]; ];
@ -571,11 +578,26 @@ function formatSlashCommandHelp(commands: SlashCommand[]): string {
.join("\n"); .join("\n");
} }
function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { 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; const { turns } = state;
if (!isReplyRecord(raw)) { if (!isReplyRecord(raw)) {
return { return {
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
progressIndex: state.progressIndex, progressIndex: state.progressIndex,
}; };
} }
@ -586,26 +608,32 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
raw.kind !== "UserPrompt" raw.kind !== "UserPrompt"
) { ) {
if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") { if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") {
return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw); return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw, receivedAtMs);
} }
return state; return state;
} }
switch (raw.kind) { switch (raw.kind) {
case "textDelta": case "textDelta":
return { return {
turns: appendAgentDelta(turns, String(raw.text ?? "")), turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs),
progressIndex: state.progressIndex, progressIndex: state.progressIndex,
}; };
case "toolActivity": case "toolActivity":
return { return {
turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }], turns: [
...turns,
{ role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs },
],
progressIndex: state.progressIndex, progressIndex: state.progressIndex,
}; };
case "progress": { case "progress": {
const progress = normalizeProgress(raw.progress); const progress = normalizeProgress(raw.progress);
if (!progress) { if (!progress) {
return { return {
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], turns: [
...turns,
{ role: "unknown", text: unknownChunkLabel(raw), receivedAtMs },
],
progressIndex: state.progressIndex, progressIndex: state.progressIndex,
}; };
} }
@ -617,7 +645,11 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
const next = [...completedState.turns]; const next = [...completedState.turns];
const last = next[next.length - 1]; const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({ role: "final", text: content }); next.push({
role: "final",
text: content,
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
});
return { turns: next, progressIndex: completedState.progressIndex }; return { turns: next, progressIndex: completedState.progressIndex };
} }
case "error": { case "error": {
@ -625,7 +657,11 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
const next = [...completedState.turns]; const next = [...completedState.turns];
const last = next[next.length - 1]; const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") }); next.push({
role: "error",
text: String(raw.message ?? "Erreur agent"),
receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs,
});
return { turns: next, progressIndex: completedState.progressIndex }; return { turns: next, progressIndex: completedState.progressIndex };
} }
case "userPrompt": case "userPrompt":
@ -635,12 +671,13 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
turns, turns,
String(raw.text ?? raw.prompt ?? ""), String(raw.text ?? raw.prompt ?? ""),
attachmentLabels(raw.attachments), attachmentLabels(raw.attachments),
receivedAtMs,
), ),
progressIndex: state.progressIndex, progressIndex: state.progressIndex,
}; };
default: default:
return { return {
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }],
progressIndex: state.progressIndex, progressIndex: state.progressIndex,
}; };
} }
@ -1318,7 +1355,13 @@ export function CustomAgentChatView({
} }
applyChatViewState({ applyChatViewState({
turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }], turns: [
{
role: "tool",
label: `Profil changé vers ${selected.name}.`,
receivedAtMs: Date.now(),
},
],
progressIndex: new Map(), progressIndex: new Map(),
}); });
setProfileDialog(null); setProfileDialog(null);
@ -1353,7 +1396,7 @@ export function CustomAgentChatView({
if (result.effect.kind === "help") { if (result.effect.kind === "help") {
const helpText = formatSlashCommandHelp(result.effect.commands); const helpText = formatSlashCommandHelp(result.effect.commands);
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "agent", text: helpText }], turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
})); }));
return; return;
@ -1375,7 +1418,11 @@ export function CustomAgentChatView({
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [ turns: [
...prev.turns, ...prev.turns,
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` }, {
role: "tool",
label: `Commande plugin ${result.command.name} exécutée.`,
receivedAtMs: Date.now(),
},
], ],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
})); }));
@ -1385,7 +1432,7 @@ export function CustomAgentChatView({
const message = `Commande ${invocation.name} échouée: ${describe(e)}`; const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
setError(message); setError(message);
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: message }], turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
})); }));
} }
@ -1500,12 +1547,14 @@ export function CustomAgentChatView({
void clearPersistedDraft(); void clearPersistedDraft();
setBusy(true); setBusy(true);
setError(null); setError(null);
const sentAtMs = Date.now();
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [ turns: [
...prev.turns, ...prev.turns,
{ {
role: "user", role: "user",
text: displayText, text: displayText,
receivedAtMs: sentAtMs,
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
}, },
], ],
@ -1541,7 +1590,10 @@ export function CustomAgentChatView({
setBusy(false); setBusy(false);
setError(describe(recoveryError)); setError(describe(recoveryError));
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: describe(recoveryError) }], turns: [
...prev.turns,
{ role: "error", text: describe(recoveryError), receivedAtMs: Date.now() },
],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
})); }));
return; return;
@ -1551,7 +1603,7 @@ export function CustomAgentChatView({
setBusy(false); setBusy(false);
setError(describe(e)); setError(describe(e));
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: describe(e) }], turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
})); }));
} }
@ -1583,6 +1635,7 @@ export function CustomAgentChatView({
stopped.kind === "structured" stopped.kind === "structured"
? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés." ? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés."
: "Agent arrêté.", : "Agent arrêté.",
receivedAtMs: Date.now(),
}, },
], ],
progressIndex: completed.progressIndex, progressIndex: completed.progressIndex,
@ -1603,7 +1656,7 @@ export function CustomAgentChatView({
return { return {
turns: [ turns: [
...completed.turns, ...completed.turns,
{ role: "tool", label: "Session déjà arrêtée." }, { role: "tool", label: "Session déjà arrêtée.", receivedAtMs: Date.now() },
], ],
progressIndex: completed.progressIndex, progressIndex: completed.progressIndex,
}; };
@ -1614,7 +1667,11 @@ export function CustomAgentChatView({
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [ turns: [
...prev.turns, ...prev.turns,
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` }, {
role: "error",
text: `Arrêt de l'agent échoué: ${describe(e)}`,
receivedAtMs: Date.now(),
},
], ],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
})); }));
@ -1966,7 +2023,11 @@ function ChatBubble({
return ( return (
<div className="flex min-w-0 justify-end"> <div className="flex min-w-0 justify-end">
<div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content"> <div className="min-w-0 max-w-[82%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<div className="mb-1 text-xs font-semibold text-success">Terminé</div> <ChatMessageMeta
label="Terminé"
receivedAtMs={turn.receivedAtMs}
labelClassName="font-semibold text-success"
/>
<p className="whitespace-pre-wrap break-words">{turn.text}</p> <p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div> </div>
</div> </div>
@ -1977,7 +2038,11 @@ function ChatBubble({
<div className="flex min-w-0 justify-center"> <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"> <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" && ( {turn.role === "error" && (
<div className="mb-1 font-semibold">Échec</div> <ChatMessageMeta
label="Échec"
receivedAtMs={turn.receivedAtMs}
labelClassName="font-semibold text-danger"
/>
)} )}
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""} {turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
<span className="whitespace-pre-wrap break-words">{turn.text}</span> <span className="whitespace-pre-wrap break-words">{turn.text}</span>
@ -1995,6 +2060,10 @@ function ChatBubble({
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10", 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> <p className="whitespace-pre-wrap break-words">{turn.text}</p>
{user && turn.attachments && turn.attachments.length > 0 && ( {user && turn.attachments && turn.attachments.length > 0 && (
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted"> <div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
@ -2016,6 +2085,50 @@ function ChatBubble({
); );
} }
function formatChatReceivedTime(receivedAtMs: number | undefined): string {
if (!Number.isFinite(receivedAtMs) || !receivedAtMs) return "";
try {
const parts = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(receivedAtMs));
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 dateTime = time && receivedAtMs ? new Date(receivedAtMs).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 { function progressTitle(progress: ReplyProgress): string {
if (isDelegationProgress(progress)) return "Délégation"; if (isDelegationProgress(progress)) return "Délégation";
if (progress.kind === "mcp") return "MCP"; if (progress.kind === "mcp") return "MCP";