feat(web): heure de réception des messages custom CLI — #210
This commit is contained in:
@ -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 () => {
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user