diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index ea9b4dc..1af2251 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -838,6 +838,18 @@ export class MockAgentGateway implements AgentGateway { } const content = `Agent: reçu « ${prompt} ».`; const streamed: ReplyChunk[] = [ + { + kind: "progress", + progress: { + source: "providerNative", + kind: "message", + stage: "delta", + label: "Analyse", + text: "Traitement du message en cours.", + provider: "mock", + nativeEvent: "mock.progress", + }, + }, { kind: "toolActivity", label: "Analyse du prompt" }, { kind: "textDelta", text: "Agent: reçu " }, { kind: "textDelta", text: `« ${prompt} ».` }, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 85cb056..219fdc9 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1650,15 +1650,34 @@ export interface TicketChat { /** * One streamed chunk of an assistant turn (mirror of the backend `ReplyChunk`, - * tagged on `kind`). `textDelta` is an incremental text fragment; `toolActivity` - * a best-effort activity badge; `final` the deterministic end-of-turn chunk - * carrying the aggregated content — after it the turn is frozen. `error` is a - * visible terminal fallback (ticket #60): the model produced no usable answer, - * so the turn ends with a human-readable explanation instead of a silent hang. + * tagged on `kind`). `textDelta` is an incremental text fragment; `progress` + * is the canonical best-effort stream of non-terminal provider/IdeA activity; + * `toolActivity` is kept as a legacy/simple activity badge; `final` is the + * deterministic end-of-turn chunk carrying the aggregated content — after it + * the turn is frozen. `error` is a visible terminal fallback (ticket #60): the + * model produced no usable answer, so the turn ends with a human-readable + * explanation instead of a silent hang. */ +export type ReplyProgressSource = "providerNative" | "ideaLocal"; +export type ReplyProgressKind = "turn" | "message" | "tool" | "mcp" | "other"; +export type ReplyProgressStage = "started" | "delta" | "completed" | "info"; + +export interface ReplyProgress { + source: ReplyProgressSource; + kind: ReplyProgressKind; + stage: ReplyProgressStage; + label: string; + text?: string; + provider?: string; + nativeEvent?: string; + toolName?: string; +} + export type ReplyChunk = + | { kind: "userPrompt"; text: string } | { kind: "textDelta"; text: string } | { kind: "toolActivity"; label: string } + | { kind: "progress"; progress: ReplyProgress } | { kind: "final"; content: string } | { kind: "error"; message: string }; diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index dcbb9d1..da1e9a0 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -205,6 +205,97 @@ describe("CustomAgentChatView", () => { expect(screen.getAllByText("hello agent")).toHaveLength(1); }); + it("renders canonical progress chunks as live agent, MCP, and delegation activity", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => { + onChunk({ + kind: "progress", + progress: { + source: "providerNative", + kind: "message", + stage: "delta", + label: "Analyse", + text: "Lecture du contexte disponible.", + provider: "codex", + nativeEvent: "agent.message.delta", + }, + }); + onChunk({ + kind: "progress", + progress: { + source: "ideaLocal", + kind: "mcp", + stage: "started", + label: "idea_ticket_read", + toolName: "idea_ticket_read", + }, + }); + onChunk({ + kind: "progress", + progress: { + source: "ideaLocal", + kind: "mcp", + stage: "started", + label: "idea_ask_agent", + text: "target=QA task=Valider le rendu progress", + toolName: "idea_ask_agent", + }, + }); + onChunk({ kind: "final", content: "done" }); + }), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + fireEvent.change(screen.getByLabelText(/message CLI custom/), { + target: { value: "ship progress UI" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await screen.findByText("Progress"); + expect(screen.getByText("Analyse")).toBeTruthy(); + expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy(); + expect(screen.getByText("MCP")).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"); + }); + it("pastes a clipboard image as a removable preview chip", async () => { const agent = { launchAgentChat: vi.fn(), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 2b91b18..bd2d686 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -15,7 +15,15 @@ import { type ClipboardEvent, } from "react"; -import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain"; +import type { + AgentProfile, + GatewayError, + ReplyChunk, + ReplyProgress, + ReplyProgressKind, + ReplyProgressSource, + ReplyProgressStage, +} from "@/domain"; import { useGateways } from "@/app/di"; import { Button, Spinner, cn } from "@/shared"; import type { ChatAttachmentInput } from "@/ports"; @@ -37,6 +45,7 @@ type ChatTurn = | { role: "user"; text: string; attachments?: string[] } | { role: "agent"; text: string; pending?: boolean } | { role: "tool"; label: string } + | { role: "progress"; progress: ReplyProgress } | { role: "final"; text: string } | { role: "error"; text: string } | { role: "unknown"; text: string }; @@ -114,6 +123,74 @@ function appendUserPrompt(turns: ChatTurn[], text: string): ChatTurn[] { return [...turns, { role: "user", text }]; } +function isReplyProgressSource(value: unknown): value is ReplyProgressSource { + return value === "providerNative" || value === "ideaLocal"; +} + +function isReplyProgressKind(value: unknown): value is ReplyProgressKind { + return ( + value === "turn" || + value === "message" || + value === "tool" || + value === "mcp" || + value === "other" + ); +} + +function isReplyProgressStage(value: unknown): value is ReplyProgressStage { + return ( + value === "started" || + value === "delta" || + value === "completed" || + value === "info" + ); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function normalizeProgress(raw: unknown): ReplyProgress | null { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + const label = optionalString(record.label); + if (!label) return null; + return { + source: isReplyProgressSource(record.source) ? record.source : "providerNative", + kind: isReplyProgressKind(record.kind) ? record.kind : "other", + stage: isReplyProgressStage(record.stage) ? record.stage : "info", + label, + text: optionalString(record.text), + provider: optionalString(record.provider), + nativeEvent: optionalString(record.nativeEvent), + toolName: optionalString(record.toolName), + }; +} + +function progressKey(progress: ReplyProgress): string { + return [ + progress.source, + progress.kind, + progress.toolName ?? "", + progress.label, + progress.text ?? "", + ].join("\u0000"); +} + +function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] { + const last = turns[turns.length - 1]; + if ( + last?.role === "progress" && + progressKey(last.progress) === progressKey(progress) + ) { + return [ + ...turns.slice(0, -1), + { role: "progress", progress }, + ]; + } + return [...turns, { role: "progress", progress }]; +} + function fileExtension(mime: string): string { if (mime === "image/png") return "png"; if (mime === "image/jpeg") return "jpg"; @@ -173,6 +250,11 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { return appendAgentDelta(turns, String(raw.text ?? "")); case "toolActivity": return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }]; + case "progress": { + const progress = normalizeProgress(raw.progress); + if (!progress) return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; + return appendProgress(turns, progress); + } case "final": { const content = String(raw.content ?? ""); const next = [...turns]; @@ -749,6 +831,9 @@ function ChatBubble({ turn }: { turn: ChatTurn }) { ); } + if (turn.role === "progress") { + return ; + } if (turn.role === "final") { return (
@@ -799,3 +884,96 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
); } + +function isDelegationProgress(progress: ReplyProgress): boolean { + const name = (progress.toolName ?? progress.label).toLowerCase(); + return name === "idea_ask_agent" || name === "idea_ask_agents"; +} + +function progressTitle(progress: ReplyProgress): string { + if (isDelegationProgress(progress)) return "Délégation"; + if (progress.kind === "mcp") return "MCP"; + if (progress.kind === "tool") return "Tool"; + if (progress.kind === "message") return "Progress"; + if (progress.kind === "turn") return "Tour"; + return "Activité"; +} + +function progressSourceLabel(progress: ReplyProgress): string { + if (progress.source === "ideaLocal") return "IdeA"; + return progress.provider ?? "Provider"; +} + +function progressStageLabel(stage: ReplyProgressStage): string { + switch (stage) { + case "started": + return "started"; + case "delta": + return "running"; + case "completed": + return "done"; + case "info": + return "info"; + } +} + +function progressTone(progress: ReplyProgress): string { + if (isDelegationProgress(progress)) { + return "border-primary/45 bg-primary/10 text-content"; + } + if (progress.kind === "mcp") { + return "border-warning/45 bg-warning/10 text-content"; + } + if (progress.kind === "tool") { + return "border-border bg-raised/70 text-content"; + } + return "border-border/80 bg-surface/70 text-muted"; +} + +function truncateProgressText(text: string): string { + return text.length > 140 ? `${text.slice(0, 137)}...` : text; +} + +function ProgressBubble({ progress }: { progress: ReplyProgress }) { + const title = progressTitle(progress); + const stage = progressStageLabel(progress.stage); + const source = progressSourceLabel(progress); + const detail = progress.text ? truncateProgressText(progress.text) : null; + const running = progress.stage === "started" || progress.stage === "delta"; + return ( +
+
+
+ {running && } + {title} + {progress.label} + · {stage} +
+
+ {source} + {progress.toolName && tool: {progress.toolName}} + {progress.nativeEvent && ( + event: {progress.nativeEvent} + )} +
+ {detail && ( +

+ {detail} +

+ )} +
+
+ ); +} diff --git a/frontend/src/features/tickets/useTicketAssistant.ts b/frontend/src/features/tickets/useTicketAssistant.ts index 440c119..029cbb9 100644 --- a/frontend/src/features/tickets/useTicketAssistant.ts +++ b/frontend/src/features/tickets/useTicketAssistant.ts @@ -144,7 +144,7 @@ export function useTicketAssistant( text: `⚠️ ${chunk.message}`, pending: false, }; - } else { + } else if (chunk.kind === "final") { // `final`: freeze the turn with the aggregated content. next[i] = { role: "assistant", text: chunk.content, pending: false }; }