diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index d16402e2..4e7357e9 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -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 { beforeEach, describe, expect, it, vi } from "vitest"; @@ -71,6 +71,17 @@ function deferred() { 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", () => { beforeEach(() => { vi.clearAllMocks(); @@ -122,6 +133,161 @@ describe("CustomAgentChatView", () => { 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( + 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: "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( + 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: "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 () => { const rectSpy = vi .spyOn(HTMLElement.prototype, "getBoundingClientRect") @@ -607,9 +773,7 @@ describe("CustomAgentChatView", () => { await screen.findByText("done"); expect(hasProjectCliInteraction("project-1")).toBe(false); expect(screen.getAllByText("hello agent")).toHaveLength(1); - expect(screen.getByText("Terminé").parentElement?.parentElement?.className).toContain( - "justify-end", - ); + expect(screen.getByText("Terminé").closest(".justify-end")).toBeTruthy(); }); it("renders canonical progress chunks as live agent, MCP, and delegation activity", async () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index e8ac72af..126f5c33 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -55,13 +55,13 @@ export interface CustomAgentChatViewProps { } type ChatTurn = - | { role: "user"; text: string; attachments?: string[] } - | { role: "agent"; text: string; pending?: boolean } - | { role: "tool"; label: string } + | { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number } + | { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number } + | { role: "tool"; label: string; receivedAtMs?: number } | { role: "progress"; progress: ReplyProgress; settled?: boolean } - | { role: "final"; text: string } - | { role: "error"; text: string } - | { role: "unknown"; text: string }; + | { role: "final"; text: string; receivedAtMs?: number } + | { role: "error"; text: string; receivedAtMs?: number } + | { role: "unknown"; text: string; receivedAtMs?: number }; interface CachedChatView { turns: ChatTurn[]; @@ -150,7 +150,11 @@ function isReplyRecord(chunk: unknown): chunk is Record { 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 last = next[next.length - 1]; if (last?.role === "agent") { @@ -158,10 +162,11 @@ function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] { role: "agent", text: last.text + text, pending: true, + receivedAtMs: last.receivedAtMs ?? receivedAtMs, }; return next; } - next.push({ role: "agent", text, pending: true }); + next.push({ role: "agent", text, pending: true, receivedAtMs }); return next; } @@ -175,6 +180,7 @@ function appendUserPrompt( turns: ChatTurn[], text: string, attachments: string[] = [], + receivedAtMs = Date.now(), ): ChatTurn[] { const last = turns[turns.length - 1]; if ( @@ -189,6 +195,7 @@ function appendUserPrompt( { role: "user", text, + receivedAtMs, ...(attachments.length > 0 ? { attachments } : {}), }, ]; @@ -571,11 +578,26 @@ function formatSlashCommandHelp(commands: SlashCommand[]): string { .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; if (!isReplyRecord(raw)) { return { - turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], + turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }], progressIndex: state.progressIndex, }; } @@ -586,26 +608,32 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { raw.kind !== "UserPrompt" ) { 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; } switch (raw.kind) { case "textDelta": return { - turns: appendAgentDelta(turns, String(raw.text ?? "")), + turns: appendAgentDelta(turns, String(raw.text ?? ""), receivedAtMs), progressIndex: state.progressIndex, }; case "toolActivity": return { - turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }], + turns: [ + ...turns, + { role: "tool", label: String(raw.label ?? "Activité"), receivedAtMs }, + ], progressIndex: state.progressIndex, }; case "progress": { const progress = normalizeProgress(raw.progress); if (!progress) { return { - turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], + turns: [ + ...turns, + { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }, + ], progressIndex: state.progressIndex, }; } @@ -617,7 +645,11 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { 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 }); + next.push({ + role: "final", + text: content, + receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs, + }); return { turns: next, progressIndex: completedState.progressIndex }; } case "error": { @@ -625,7 +657,11 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { 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") }); + next.push({ + role: "error", + text: String(raw.message ?? "Erreur agent"), + receivedAtMs: lastAgentReceivedAtMs(next) ?? receivedAtMs, + }); return { turns: next, progressIndex: completedState.progressIndex }; } case "userPrompt": @@ -635,12 +671,13 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState { turns, String(raw.text ?? raw.prompt ?? ""), attachmentLabels(raw.attachments), + receivedAtMs, ), progressIndex: state.progressIndex, }; default: return { - turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }], + turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw), receivedAtMs }], progressIndex: state.progressIndex, }; } @@ -1318,7 +1355,13 @@ export function CustomAgentChatView({ } 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(), }); setProfileDialog(null); @@ -1353,7 +1396,7 @@ export function CustomAgentChatView({ if (result.effect.kind === "help") { const helpText = formatSlashCommandHelp(result.effect.commands); updateChatViewState((prev) => ({ - turns: [...prev.turns, { role: "agent", text: helpText }], + turns: [...prev.turns, { role: "agent", text: helpText, receivedAtMs: Date.now() }], progressIndex: prev.progressIndex, })); return; @@ -1375,7 +1418,11 @@ export function CustomAgentChatView({ updateChatViewState((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, })); @@ -1385,7 +1432,7 @@ export function CustomAgentChatView({ const message = `Commande ${invocation.name} échouée: ${describe(e)}`; setError(message); updateChatViewState((prev) => ({ - turns: [...prev.turns, { role: "error", text: message }], + turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }], progressIndex: prev.progressIndex, })); } @@ -1500,12 +1547,14 @@ export function CustomAgentChatView({ void clearPersistedDraft(); setBusy(true); setError(null); + const sentAtMs = Date.now(); updateChatViewState((prev) => ({ turns: [ ...prev.turns, { role: "user", text: displayText, + receivedAtMs: sentAtMs, ...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), }, ], @@ -1541,7 +1590,10 @@ export function CustomAgentChatView({ setBusy(false); setError(describe(recoveryError)); updateChatViewState((prev) => ({ - turns: [...prev.turns, { role: "error", text: describe(recoveryError) }], + turns: [ + ...prev.turns, + { role: "error", text: describe(recoveryError), receivedAtMs: Date.now() }, + ], progressIndex: prev.progressIndex, })); return; @@ -1551,7 +1603,7 @@ export function CustomAgentChatView({ setBusy(false); setError(describe(e)); updateChatViewState((prev) => ({ - turns: [...prev.turns, { role: "error", text: describe(e) }], + turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }], progressIndex: prev.progressIndex, })); } @@ -1583,6 +1635,7 @@ export function CustomAgentChatView({ stopped.kind === "structured" ? "Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés." : "Agent arrêté.", + receivedAtMs: Date.now(), }, ], progressIndex: completed.progressIndex, @@ -1603,7 +1656,7 @@ export function CustomAgentChatView({ return { 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, }; @@ -1614,7 +1667,11 @@ export function CustomAgentChatView({ updateChatViewState((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, })); @@ -1966,7 +2023,11 @@ function ChatBubble({ return (
-
Terminé
+

{turn.text}

@@ -1977,7 +2038,11 @@ function ChatBubble({
{turn.role === "error" && ( -
Échec
+ )} {turn.role === "unknown" ? "Chunk inconnu reçu: " : ""} {turn.text} @@ -1995,6 +2060,10 @@ function ChatBubble({ user ? "border-border bg-raised" : "border-primary/25 bg-primary/10", )} > +

{turn.text}

{user && turn.attachments && turn.attachments.length > 0 && (
@@ -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 ( +
+ + {label} + + {time && ( + + )} +
+ ); +} + function progressTitle(progress: ReplyProgress): string { if (isDelegationProgress(progress)) return "Délégation"; if (progress.kind === "mcp") return "MCP";