merge(agents): repense la timeline de conversation — tools MCP centrés, progress à droite, tours compacts — #174 (QA verte)
This commit is contained in:
@ -252,6 +252,17 @@ describe("CustomAgentChatView", () => {
|
||||
scrollback: [],
|
||||
})),
|
||||
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({
|
||||
kind: "progress",
|
||||
progress: {
|
||||
@ -285,6 +296,17 @@ describe("CustomAgentChatView", () => {
|
||||
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" });
|
||||
}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
@ -326,13 +348,19 @@ describe("CustomAgentChatView", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
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("Lecture du contexte disponible.")).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("Délégation")).toBeTruthy();
|
||||
expect(screen.getByText("target=QA task=Valider le rendu progress")).toBeTruthy();
|
||||
await screen.findByText("done");
|
||||
expect(screen.getByText("Demande à QA")).toBeTruthy();
|
||||
expect(screen.getByText("Demande à QA").closest("[data-testid='chat-tool-progress']")).toBeTruthy();
|
||||
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 () => {
|
||||
@ -529,8 +557,9 @@ describe("CustomAgentChatView", () => {
|
||||
});
|
||||
|
||||
await screen.findByText("done");
|
||||
expect(screen.getAllByText("Délégation")).toHaveLength(2);
|
||||
expect(screen.getByText("target=QA task=validate")).toBeTruthy();
|
||||
expect(screen.getByText("Demande à QA")).toBeTruthy();
|
||||
expect(screen.getByText("validate")).toBeTruthy();
|
||||
expect(screen.getByText("Appel inter-agent")).toBeTruthy();
|
||||
expect(screen.getByText("reply delivered")).toBeTruthy();
|
||||
expect(screen.queryByText("Tool")).toBeNull();
|
||||
await waitFor(() =>
|
||||
|
||||
@ -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 data-testid="chat-progress-status" className="flex min-w-0 justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
isToolLikeProgress(progress) ? "justify-start pl-7" : "justify-center",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user