feat(cli): repense la timeline de conversation (tools MCP centrés, progress à droite, tours compacts) — #174 (QA verte)

- ToolProgressCard: tools/MCP/délégations centrés, extrait succinct de la demande inter-agent

- ProgressBubble: progress non-outil alignés à droite, clamp 4 lignes (fourLineClamp)

- TurnMarker: marqueurs turn.started/turn.completed compacts et centrés

- Délégations explicites (« Demande à <target> », « Appel inter-agent ») + libellés FR (Terminé)
This commit is contained in:
2026-08-11 10:28:04 +02:00
parent d9894e0a28
commit ae3c55a13d
2 changed files with 173 additions and 27 deletions

View File

@ -252,6 +252,17 @@ describe("CustomAgentChatView", () => {
scrollback: [], scrollback: [],
})), })),
sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => { sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => {
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "turn",
stage: "started",
label: "turn.started",
provider: "codex",
nativeEvent: "turn.started",
},
});
onChunk({ onChunk({
kind: "progress", kind: "progress",
progress: { progress: {
@ -285,6 +296,17 @@ describe("CustomAgentChatView", () => {
toolName: "idea_ask_agent", toolName: "idea_ask_agent",
}, },
}); });
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "turn",
stage: "completed",
label: "turn.completed",
provider: "codex",
nativeEvent: "turn.completed",
},
});
onChunk({ kind: "final", content: "done" }); onChunk({ kind: "final", content: "done" });
}), }),
cancelAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}),
@ -326,13 +348,19 @@ describe("CustomAgentChatView", () => {
fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("Progress"); await screen.findByText("Progress");
expect(screen.getAllByTestId("chat-turn-marker")).toHaveLength(2);
expect(screen.getByText("turn.started")).toBeTruthy();
expect(screen.getByText("turn.completed")).toBeTruthy();
expect(screen.getByText("Progress").closest("[data-testid='chat-progress-status']")).toBeTruthy();
expect(screen.getByText("Analyse")).toBeTruthy(); expect(screen.getByText("Analyse")).toBeTruthy();
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy(); expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
expect(screen.getByText("MCP")).toBeTruthy(); expect(screen.getByText("MCP")).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("Délégation")).toBeTruthy(); expect(screen.getByText("Demande à QA")).toBeTruthy();
expect(screen.getByText("target=QA task=Valider le rendu progress")).toBeTruthy(); expect(screen.getByText("Demande à QA").closest("[data-testid='chat-tool-progress']")).toBeTruthy();
await screen.findByText("done"); expect(screen.getByText("Valider le rendu progress")).toBeTruthy();
expect((await screen.findAllByText("done")).length).toBeGreaterThan(0);
}); });
it("renders available pre-MCP progress before the first MCP card", async () => { it("renders available pre-MCP progress before the first MCP card", async () => {
@ -529,8 +557,9 @@ describe("CustomAgentChatView", () => {
}); });
await screen.findByText("done"); await screen.findByText("done");
expect(screen.getAllByText("Délégation")).toHaveLength(2); expect(screen.getByText("Demande à QA")).toBeTruthy();
expect(screen.getByText("target=QA task=validate")).toBeTruthy(); expect(screen.getByText("validate")).toBeTruthy();
expect(screen.getByText("Appel inter-agent")).toBeTruthy();
expect(screen.getByText("reply delivered")).toBeTruthy(); expect(screen.getByText("reply delivered")).toBeTruthy();
expect(screen.queryByText("Tool")).toBeNull(); expect(screen.queryByText("Tool")).toBeNull();
await waitFor(() => await waitFor(() =>

View File

@ -204,10 +204,6 @@ function isDelegationProgress(progress: ReplyProgress): boolean {
return name === "idea_ask_agent" || name === "idea_ask_agents"; return name === "idea_ask_agent" || name === "idea_ask_agents";
} }
function isToolLikeProgress(progress: ReplyProgress): boolean {
return progress.kind === "tool" || progress.kind === "mcp" || isDelegationProgress(progress);
}
function progressEntityKey(progress: ReplyProgress): string | null { function progressEntityKey(progress: ReplyProgress): string | null {
const name = normalizedProgressToolName(progress); const name = normalizedProgressToolName(progress);
if (!name) return null; if (!name) return null;
@ -217,7 +213,7 @@ function progressEntityKey(progress: ReplyProgress): string | null {
} }
function progressStageBucket(progress: ReplyProgress): string { function progressStageBucket(progress: ReplyProgress): string {
if (!isToolLikeProgress(progress)) return progress.stage; if (!isCenteredToolProgress(progress)) return progress.stage;
if (progress.stage === "started" || progress.stage === "delta") return "started"; if (progress.stage === "started" || progress.stage === "delta") return "started";
if (progress.stage === "completed") return "completed"; if (progress.stage === "completed") return "completed";
return "info"; return "info";
@ -255,6 +251,7 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
return turns.map((turn) => { return turns.map((turn) => {
if ( if (
turn.role !== "progress" || turn.role !== "progress" ||
turn.progress.kind === "turn" ||
(turn.progress.stage !== "started" && turn.progress.stage !== "delta") (turn.progress.stage !== "started" && turn.progress.stage !== "delta")
) { ) {
return turn; return turn;
@ -1316,9 +1313,9 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
} }
if (turn.role === "final") { if (turn.role === "final") {
return ( return (
<div className="flex min-w-0 justify-end"> <div className="flex min-w-0 justify-start">
<div className="min-w-0 max-w-[86%] 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">Task Complete</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>
</div> </div>
</div> </div>
@ -1337,7 +1334,7 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
const user = turn.role === "user"; const user = turn.role === "user";
return ( return (
<div className={cn("flex min-w-0", user ? "justify-start" : "justify-end")}> <div className="flex min-w-0 justify-start">
<div <div
className={cn( className={cn(
"min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content", "min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content",
@ -1405,8 +1402,127 @@ function progressTone(progress: ReplyProgress): string {
return "border-border/80 bg-surface/70 text-muted"; return "border-border/80 bg-surface/70 text-muted";
} }
function truncateProgressText(text: string): string { function isCenteredToolProgress(progress: ReplyProgress): boolean {
return text.length > 140 ? `${text.slice(0, 137)}...` : text; return (
progress.kind === "tool" ||
progress.kind === "mcp" ||
isDelegationProgress(progress)
);
}
function isTimelineProgress(progress: ReplyProgress): boolean {
return progress.kind === "turn";
}
function compactToolName(progress: ReplyProgress): string {
return progress.toolName ?? progress.label;
}
function truncateText(text: string, max = 140): string {
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
}
function parseKeyValue(text: string, key: string): string | null {
const match = new RegExp(
`(?:^|\\s)${key}=([^\\n]+?)(?=\\s\\w+=|$)`,
"i",
).exec(text);
return match?.[1]?.trim().replace(/^["']|["']$/g, "") || null;
}
function delegationSummary(progress: ReplyProgress): {
target: string | null;
excerpt: string | null;
} {
const text = progress.text ?? "";
const target = parseKeyValue(text, "target");
const task = parseKeyValue(text, "task");
const fallback = text && !target && !task ? text : null;
return {
target,
excerpt: task
? truncateText(task, 92)
: fallback
? truncateText(fallback, 92)
: null,
};
}
const fourLineClamp = {
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 4,
} as const;
function TurnMarker({ progress }: { progress: ReplyProgress }) {
const stage = progressStageLabel(progress.stage);
const label = progress.label.trim() || "Tour";
return (
<div data-testid="chat-turn-marker" className="flex min-w-0 justify-center">
<div className="flex max-w-[72%] items-center gap-2 overflow-hidden text-[11px] text-muted">
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
<span className="shrink-0 rounded-full border border-border bg-surface px-2 py-0.5">
{stage}
</span>
<span className="truncate">{label}</span>
<span className="h-px w-8 shrink-0 bg-border" aria-hidden />
</div>
</div>
);
}
function ToolProgressCard({
progress,
settled,
}: {
progress: ReplyProgress;
settled: boolean;
}) {
const running = !settled && (progress.stage === "started" || progress.stage === "delta");
const stage = progressStageLabel(progress.stage);
const detail =
progress.text && !isDelegationProgress(progress) ? truncateText(progress.text) : null;
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
const title = delegation
? delegation.target
? `Demande à ${delegation.target}`
: "Appel inter-agent"
: progressTitle(progress);
return (
<div data-testid="chat-tool-progress" className="flex min-w-0 justify-center">
<div
className={cn(
"min-w-0 max-w-[72%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
progressTone(progress),
)}
>
<div className="flex min-w-0 items-center justify-center gap-1.5">
{running && <Spinner size={12} />}
<span className="shrink-0 font-semibold">{title}</span>
{!delegation && (
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
)}
<span className="shrink-0 text-muted">· {stage}</span>
</div>
{delegation?.excerpt && (
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
{delegation.excerpt}
</p>
)}
{delegation && (
<p className="mt-0.5 truncate text-center text-[11px] text-muted">
{compactToolName(progress)}
</p>
)}
{detail && (
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
{detail}
</p>
)}
</div>
</div>
);
} }
function ProgressBubble({ function ProgressBubble({
@ -1416,21 +1532,20 @@ function ProgressBubble({
progress: ReplyProgress; progress: ReplyProgress;
settled?: boolean; settled?: boolean;
}) { }) {
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
if (isCenteredToolProgress(progress)) {
return <ToolProgressCard progress={progress} settled={settled} />;
}
const title = progressTitle(progress); const title = progressTitle(progress);
const stage = progressStageLabel(progress.stage); const stage = progressStageLabel(progress.stage);
const source = progressSourceLabel(progress); const source = progressSourceLabel(progress);
const detail = progress.text ? truncateProgressText(progress.text) : null; const detail = progress.text ? truncateText(progress.text, 260) : null;
const running = !settled && (progress.stage === "started" || progress.stage === "delta"); const running = !settled && (progress.stage === "started" || progress.stage === "delta");
return ( return (
<div <div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
className={cn(
"flex min-w-0",
isToolLikeProgress(progress) ? "justify-start pl-7" : "justify-center",
)}
>
<div <div
className={cn( className={cn(
"min-w-0 max-w-[86%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs", "min-w-0 max-w-[58%] overflow-hidden rounded-md border px-2.5 py-1.5 text-xs",
progressTone(progress), progressTone(progress),
)} )}
> >
@ -1440,15 +1555,17 @@ function ProgressBubble({
<span className="min-w-0 truncate text-muted">{progress.label}</span> <span className="min-w-0 truncate text-muted">{progress.label}</span>
<span className="shrink-0 text-muted">· {stage}</span> <span className="shrink-0 text-muted">· {stage}</span>
</div> </div>
<div className="mt-0.5 flex min-w-0 flex-wrap gap-x-2 gap-y-0.5 text-[11px] text-muted"> <div className="mt-0.5 flex min-w-0 flex-wrap justify-end gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{source}</span> <span>{source}</span>
{progress.toolName && <span className="truncate">tool: {progress.toolName}</span>}
{progress.nativeEvent && ( {progress.nativeEvent && (
<span className="truncate">event: {progress.nativeEvent}</span> <span className="truncate">event: {progress.nativeEvent}</span>
)} )}
</div> </div>
{detail && ( {detail && (
<p className="mt-1 whitespace-pre-wrap break-words text-content"> <p
className="mt-1 overflow-hidden whitespace-pre-wrap break-words text-content"
style={fourLineClamp}
>
{detail} {detail}
</p> </p>
)} )}