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

@ -204,10 +204,6 @@ function isDelegationProgress(progress: ReplyProgress): boolean {
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 {
const name = normalizedProgressToolName(progress);
if (!name) return null;
@ -217,7 +213,7 @@ function progressEntityKey(progress: ReplyProgress): string | null {
}
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 === "completed") return "completed";
return "info";
@ -255,6 +251,7 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
return turns.map((turn) => {
if (
turn.role !== "progress" ||
turn.progress.kind === "turn" ||
(turn.progress.stage !== "started" && turn.progress.stage !== "delta")
) {
return turn;
@ -1316,9 +1313,9 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
}
if (turn.role === "final") {
return (
<div className="flex min-w-0 justify-end">
<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="mb-1 text-xs font-semibold text-success">Task Complete</div>
<div className="flex min-w-0 justify-start">
<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>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div>
</div>
@ -1337,7 +1334,7 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
const user = turn.role === "user";
return (
<div className={cn("flex min-w-0", user ? "justify-start" : "justify-end")}>
<div className="flex min-w-0 justify-start">
<div
className={cn(
"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";
}
function truncateProgressText(text: string): string {
return text.length > 140 ? `${text.slice(0, 137)}...` : text;
function isCenteredToolProgress(progress: ReplyProgress): boolean {
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({
@ -1416,21 +1532,20 @@ function ProgressBubble({
progress: ReplyProgress;
settled?: boolean;
}) {
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
if (isCenteredToolProgress(progress)) {
return <ToolProgressCard progress={progress} settled={settled} />;
}
const title = progressTitle(progress);
const stage = progressStageLabel(progress.stage);
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");
return (
<div
className={cn(
"flex min-w-0",
isToolLikeProgress(progress) ? "justify-start pl-7" : "justify-center",
)}
>
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
<div
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),
)}
>
@ -1440,15 +1555,17 @@ function ProgressBubble({
<span className="min-w-0 truncate text-muted">{progress.label}</span>
<span className="shrink-0 text-muted">· {stage}</span>
</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>
{progress.toolName && <span className="truncate">tool: {progress.toolName}</span>}
{progress.nativeEvent && (
<span className="truncate">event: {progress.nativeEvent}</span>
)}
</div>
{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}
</p>
)}