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:
@ -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(() =>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user