feat(cli): aligne les Final à droite, tools MCP « qui parle à qui », icône animée seulement pendant l'exécution (started→done), supprime le message de fin de tool redondant — #176 (QA verte)

This commit is contained in:
2026-08-11 11:24:44 +02:00
parent 66afd3584a
commit 84fd3547a9
2 changed files with 98 additions and 13 deletions

View File

@ -242,6 +242,9 @@ describe("CustomAgentChatView", () => {
await screen.findByText("done"); await screen.findByText("done");
expect(screen.getAllByText("hello agent")).toHaveLength(1); expect(screen.getAllByText("hello agent")).toHaveLength(1);
expect(screen.getByText("Terminé").parentElement?.parentElement?.className).toContain(
"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 () => {
@ -357,8 +360,8 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("MCP")).toBeTruthy(); expect(screen.getByText("MCP")).toBeTruthy();
expect(screen.getByText("MCP").closest("[data-testid='chat-tool-progress']")).toBeTruthy(); expect(screen.getByText("MCP").closest("[data-testid='chat-tool-progress']")).toBeTruthy();
expect(screen.getByText("idea_ticket_read")).toBeTruthy(); expect(screen.getByText("idea_ticket_read")).toBeTruthy();
expect(screen.getByText("Demande à QA")).toBeTruthy(); expect(screen.getByText("Worker ← QA")).toBeTruthy();
expect(screen.getByText("Demande à QA").closest("[data-testid='chat-tool-progress']")).toBeTruthy(); expect(screen.getByText("Worker ← QA").closest("[data-testid='chat-tool-progress']")).toBeTruthy();
expect(screen.getByText("Valider le rendu progress")).toBeTruthy(); expect(screen.getByText("Valider le rendu progress")).toBeTruthy();
expect((await screen.findAllByText("done")).length).toBeGreaterThan(0); expect((await screen.findAllByText("done")).length).toBeGreaterThan(0);
}); });
@ -547,6 +550,13 @@ describe("CustomAgentChatView", () => {
act(() => { act(() => {
emitChunk?.(progress("providerNative", "started")); emitChunk?.(progress("providerNative", "started"));
emitChunk?.(progress("ideaLocal", "started")); emitChunk?.(progress("ideaLocal", "started"));
});
expect(screen.getByText("Worker → QA")).toBeTruthy();
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started");
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
act(() => {
emitChunk?.(progress("providerNative", "completed")); emitChunk?.(progress("providerNative", "completed"));
emitChunk?.(progress("ideaLocal", "completed")); emitChunk?.(progress("ideaLocal", "completed"));
emitChunk?.(progress("providerNative", "started")); emitChunk?.(progress("providerNative", "started"));
@ -557,11 +567,12 @@ describe("CustomAgentChatView", () => {
}); });
await screen.findByText("done"); await screen.findByText("done");
expect(screen.getByText("Demande à QA")).toBeTruthy(); expect(screen.getAllByText("Worker ← QA")).toHaveLength(2);
expect(screen.getByText("validate")).toBeTruthy(); expect(screen.getAllByText("validate")).toHaveLength(2);
expect(screen.getByText("Appel inter-agent")).toBeTruthy(); expect(screen.queryByText("Appel inter-agent")).toBeNull();
expect(screen.getByText("reply delivered")).toBeTruthy(); expect(screen.queryByText("reply delivered")).toBeNull();
expect(screen.queryByText("Tool")).toBeNull(); expect(screen.queryByText("Tool")).toBeNull();
expect(screen.queryByText("started")).toBeNull();
await waitFor(() => await waitFor(() =>
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(), expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
); );

View File

@ -226,6 +226,32 @@ function progressDisplayKey(progress: ReplyProgress): string {
} }
function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] { function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] {
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 (runningIndex >= 0) {
const next = [...turns];
const current = next[runningIndex];
if (current.role === "progress") {
next[runningIndex] = {
role: "progress",
progress: mergeCompletedProgress(current.progress, progress),
};
}
return next;
}
}
const key = progressDisplayKey(progress); const key = progressDisplayKey(progress);
if ( if (
turns.some( turns.some(
@ -237,6 +263,18 @@ function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[]
return [...turns, { role: "progress", progress }]; return [...turns, { role: "progress", progress }];
} }
function mergeCompletedProgress(
running: ReplyProgress,
completed: ReplyProgress,
): ReplyProgress {
if (!isDelegationProgress(running) || !completed.text) return completed;
if (delegationSummary(completed).target) return completed;
return {
...completed,
text: running.text,
};
}
function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] { function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
const completedEntities = new Set( const completedEntities = new Set(
turns turns
@ -258,7 +296,14 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
} }
const entity = progressEntityKey(turn.progress); const entity = progressEntityKey(turn.progress);
if (entity && completedEntities.has(entity)) { if (entity && completedEntities.has(entity)) {
return { ...turn, settled: true }; return {
...turn,
settled: true,
progress: {
...turn.progress,
stage: "completed",
},
};
} }
return { return {
...turn, ...turn,
@ -1146,7 +1191,9 @@ export function CustomAgentChatView({
Envoyez un message pour démarrer la conversation structurée. Envoyez un message pour démarrer la conversation structurée.
</p> </p>
) : ( ) : (
turns.map((turn, index) => <ChatBubble key={index} turn={turn} />) turns.map((turn, index) => (
<ChatBubble key={index} turn={turn} requesterName={agentName} />
))
)} )}
</div> </div>
@ -1298,7 +1345,13 @@ export function CustomAgentChatView({
); );
} }
function ChatBubble({ turn }: { turn: ChatTurn }) { function ChatBubble({
turn,
requesterName,
}: {
turn: ChatTurn;
requesterName: string;
}) {
if (turn.role === "tool") { if (turn.role === "tool") {
return ( return (
<div className="flex min-w-0 justify-center"> <div className="flex min-w-0 justify-center">
@ -1309,11 +1362,17 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
); );
} }
if (turn.role === "progress") { if (turn.role === "progress") {
return <ProgressBubble progress={turn.progress} settled={turn.settled} />; return (
<ProgressBubble
progress={turn.progress}
requesterName={requesterName}
settled={turn.settled}
/>
);
} }
if (turn.role === "final") { if (turn.role === "final") {
return ( return (
<div className="flex min-w-0 justify-start"> <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> <div className="mb-1 text-xs font-semibold text-success">Terminé</div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p> <p className="whitespace-pre-wrap break-words">{turn.text}</p>
@ -1432,14 +1491,17 @@ function parseKeyValue(text: string, key: string): string | null {
} }
function delegationSummary(progress: ReplyProgress): { function delegationSummary(progress: ReplyProgress): {
requester: string | null;
target: string | null; target: string | null;
excerpt: string | null; excerpt: string | null;
} { } {
const text = progress.text ?? ""; const text = progress.text ?? "";
const requester = parseKeyValue(text, "requester");
const target = parseKeyValue(text, "target"); const target = parseKeyValue(text, "target");
const task = parseKeyValue(text, "task"); const task = parseKeyValue(text, "task");
const fallback = text && !target && !task ? text : null; const fallback = text && !target && !task ? text : null;
return { return {
requester,
target, target,
excerpt: task excerpt: task
? truncateText(task, 92) ? truncateText(task, 92)
@ -1474,9 +1536,11 @@ function TurnMarker({ progress }: { progress: ReplyProgress }) {
function ToolProgressCard({ function ToolProgressCard({
progress, progress,
requesterName,
settled, settled,
}: { }: {
progress: ReplyProgress; progress: ReplyProgress;
requesterName: string;
settled: boolean; settled: boolean;
}) { }) {
const running = !settled && (progress.stage === "started" || progress.stage === "delta"); const running = !settled && (progress.stage === "started" || progress.stage === "delta");
@ -1486,7 +1550,9 @@ function ToolProgressCard({
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null; const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
const title = delegation const title = delegation
? delegation.target ? delegation.target
? `Demande à ${delegation.target}` ? `${delegation.requester ?? requesterName} ${
progress.stage === "completed" ? "←" : "→"
} ${delegation.target}`
: "Appel inter-agent" : "Appel inter-agent"
: progressTitle(progress); : progressTitle(progress);
return ( return (
@ -1527,14 +1593,22 @@ function ToolProgressCard({
function ProgressBubble({ function ProgressBubble({
progress, progress,
requesterName,
settled = false, settled = false,
}: { }: {
progress: ReplyProgress; progress: ReplyProgress;
requesterName: string;
settled?: boolean; settled?: boolean;
}) { }) {
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />; if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
if (isCenteredToolProgress(progress)) { if (isCenteredToolProgress(progress)) {
return <ToolProgressCard progress={progress} settled={settled} />; return (
<ToolProgressCard
progress={progress}
requesterName={requesterName}
settled={settled}
/>
);
} }
const title = progressTitle(progress); const title = progressTitle(progress);
const stage = progressStageLabel(progress.stage); const stage = progressStageLabel(progress.stage);