diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 18e15003..554811d6 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -288,6 +288,66 @@ describe("CustomAgentChatView", () => { } }); + it("replays scrollback with the current reception time instead of the reduce index", async () => { + const replayReceivedAtMs = new Date("2026-08-15T13:08:00.000Z").getTime(); + const indexOneTime = expectedChatTime(1); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(replayReceivedAtMs); + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [ + { kind: "userPrompt" as const, text: "Replay prompt" }, + { kind: "textDelta" as const, text: "Replay answer" }, + { kind: "final" as const, content: "Replay completed" }, + ], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + try { + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + const agentBubble = (await screen.findByText("Replay answer")).closest("div"); + const finalBubble = screen.getByText("Replay completed").closest("div"); + + expect(agentBubble).toBeTruthy(); + expect(finalBubble).toBeTruthy(); + expect( + within(agentBubble!).getByText(expectedChatTime(replayReceivedAtMs)), + ).toBeTruthy(); + expect( + within(finalBubble!).getByText(expectedChatTime(replayReceivedAtMs)), + ).toBeTruthy(); + expect(within(agentBubble!).queryByText(indexOneTime)).toBeNull(); + expect(within(finalBubble!).queryByText(indexOneTime)).toBeNull(); + } finally { + nowSpy.mockRestore(); + } + }); + it("resizes the composer vertically with mouse, keyboard, and double-click reset", async () => { const rectSpy = vi .spyOn(HTMLElement.prototype, "getBoundingClientRect") diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 6439ac46..3b4837d9 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -75,6 +75,14 @@ const chatConversationViewCache = new Map(); const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const COMPOSER_RESIZE_STEP = 12; const FALLBACK_COMPOSER_HEIGHT = 64; +const MIN_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2020, 0, 1); +const MAX_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2100, 0, 1); +const MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor( + MIN_REASONABLE_CHAT_TIMESTAMP_MS / 1000, +); +const MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor( + MAX_REASONABLE_CHAT_TIMESTAMP_MS / 1000, +); export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); @@ -1120,7 +1128,10 @@ export function CustomAgentChatView({ setCurrentSession(reattached.sessionId); if (options.applyScrollback) { scrollbackChunkCountRef.current = reattached.scrollback.length; - const replayed = reattached.scrollback.reduce(foldChunk, emptyChatViewState()); + const replayed = reattached.scrollback.reduce( + (state, chunk) => foldChunk(state, chunk), + emptyChatViewState(), + ); applyChatViewState(replayed); setBusy(turnsIndicateBusy(replayed)); } @@ -2175,14 +2186,34 @@ function ChatBubble({ ); } +function normalizeChatReceivedAtMs(receivedAtMs: number | undefined): number | undefined { + if (!Number.isFinite(receivedAtMs) || receivedAtMs === undefined || receivedAtMs <= 0) { + return undefined; + } + if ( + receivedAtMs >= MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS && + receivedAtMs < MAX_REASONABLE_CHAT_TIMESTAMP_SECONDS + ) { + return receivedAtMs * 1000; + } + if ( + receivedAtMs < MIN_REASONABLE_CHAT_TIMESTAMP_MS || + receivedAtMs >= MAX_REASONABLE_CHAT_TIMESTAMP_MS + ) { + return undefined; + } + return receivedAtMs; +} + function formatChatReceivedTime(receivedAtMs: number | undefined): string { - if (!Number.isFinite(receivedAtMs) || !receivedAtMs) return ""; + const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs); + if (!normalizedAtMs) return ""; try { const parts = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23", - }).formatToParts(new Date(receivedAtMs)); + }).formatToParts(new Date(normalizedAtMs)); const hour = parts.find((part) => part.type === "hour")?.value; const minute = parts.find((part) => part.type === "minute")?.value; return hour && minute ? `${hour}:${minute}` : ""; @@ -2201,7 +2232,8 @@ function ChatMessageMeta({ labelClassName?: string; }) { const time = formatChatReceivedTime(receivedAtMs); - const dateTime = time && receivedAtMs ? new Date(receivedAtMs).toISOString() : undefined; + const normalizedAtMs = normalizeChatReceivedAtMs(receivedAtMs); + const dateTime = time && normalizedAtMs ? new Date(normalizedAtMs).toISOString() : undefined; return (