From e20182fa5e86762bd4829beba78f2b970cc7beda Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 21 Aug 2026 16:09:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20messages=20progress=20et=20usages?= =?UTF-8?q?=20MCP=20d=C3=A9pliables/repliables,=20repli=C3=A9s=20par=20d?= =?UTF-8?q?=C3=A9faut=20=E2=80=94=20#233?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/CustomAgentChatView.test.tsx | 136 +++++++++++++ .../features/agents/CustomAgentChatView.tsx | 179 ++++++++++++++++-- 2 files changed, 298 insertions(+), 17 deletions(-) diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 8166067d..606cadaa 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -82,6 +82,21 @@ function expectedChatTime(ms: number): string { return `${hour}:${minute}`; } +function setMeasuredTextOverflow( + element: Element, + { scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }, +) { + Object.defineProperty(element, "scrollHeight", { + value: scrollHeight, + configurable: true, + }); + Object.defineProperty(element, "clientHeight", { + value: clientHeight, + configurable: true, + }); + fireEvent(window, new Event("resize")); +} + describe("CustomAgentChatView", () => { beforeEach(() => { vi.clearAllMocks(); @@ -1493,6 +1508,127 @@ describe("CustomAgentChatView", () => { expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours"); }); + it("expands only truncated MCP progress details and preserves expansion across updates", async () => { + let emitChunk: ((chunk: unknown) => void) | null = null; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn( + (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => + new Promise((resolve) => { + emitChunk = (chunk: unknown) => { + onChunk(chunk); + if ((chunk as { kind?: string }).kind === "final") resolve(); + }; + }), + ), + 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: "inspect mcp payloads" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(emitChunk).not.toBeNull()); + + act(() => { + emitChunk?.({ + kind: "progress" as const, + progress: { + progressId: "mcp-large", + source: "ideaLocal" as const, + kind: "mcp" as const, + stage: "started" as const, + label: "idea_ticket_read", + toolName: "idea_ticket_read", + text: "line 1\nline 2\nline 3\nline 4\nline 5", + }, + }); + emitChunk?.({ + kind: "progress" as const, + progress: { + progressId: "mcp-small", + source: "ideaLocal" as const, + kind: "mcp" as const, + stage: "started" as const, + label: "idea_memory_read", + toolName: "idea_memory_read", + text: "short payload", + }, + }); + }); + + const details = await screen.findAllByTestId("chat-progress-detail"); + expect(details).toHaveLength(2); + setMeasuredTextOverflow(details[0], { scrollHeight: 120, clientHeight: 48 }); + setMeasuredTextOverflow(details[1], { scrollHeight: 24, clientHeight: 24 }); + + const expand = await screen.findByRole("button", { name: "Afficher plus" }); + expect(expand.getAttribute("aria-expanded")).toBe("false"); + expect(expand.getAttribute("aria-controls")).toBe(details[0].id); + expect(screen.queryAllByRole("button", { name: "Afficher plus" })).toHaveLength(1); + + fireEvent.click(expand); + expect( + screen.getByRole("button", { name: "Afficher moins" }).getAttribute("aria-expanded"), + ).toBe("true"); + expect(screen.queryByRole("button", { name: "Afficher plus" })).toBeNull(); + + act(() => { + emitChunk?.({ + kind: "progress" as const, + progress: { + progressId: "mcp-large", + source: "ideaLocal" as const, + kind: "mcp" as const, + stage: "delta" as const, + label: "idea_ticket_read", + toolName: "idea_ticket_read", + text: "updated line 1\nupdated line 2\nupdated line 3\nupdated line 4\nupdated line 5", + }, + }); + }); + + expect(screen.getByText(/updated line 5/)).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Afficher moins" }).getAttribute("aria-expanded"), + ).toBe("true"); + expect(screen.queryByRole("button", { name: "Afficher plus" })).toBeNull(); + }); + it("renders progress immediately when the chunk callback fires", async () => { let emitChunk: ((chunk: unknown) => void) | null = null; const agent = { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index b66f3524..1f37b881 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -9,12 +9,15 @@ import { useCallback, useEffect, + useId, + useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent, + type RefObject, } from "react"; import type { @@ -767,6 +770,9 @@ export function CustomAgentChatView({ ? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map()) : new Map(), ); + const [expandedProgressKeys, setExpandedProgressKeys] = useState>( + () => new Set(), + ); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); const [draft, setDraft] = useState(""); @@ -1988,7 +1994,23 @@ export function CustomAgentChatView({

) : ( displayTurns.map((turn, index) => ( - + + setExpandedProgressKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }) + } + /> )) )} @@ -2166,9 +2188,13 @@ export function CustomAgentChatView({ function ChatBubble({ turn, requesterName, + expandedProgressKeys, + onToggleProgressExpanded, }: { turn: ChatTurn; requesterName: string; + expandedProgressKeys: Set; + onToggleProgressExpanded: (key: string) => void; }) { if (turn.role === "tool") { return ( @@ -2180,10 +2206,14 @@ function ChatBubble({ ); } if (turn.role === "progress") { + const key = progressUpsertKey(turn.progress); return ( onToggleProgressExpanded(key)} /> ); } @@ -2401,6 +2431,7 @@ function parseKeyValue(text: string, key: string): string | null { function delegationSummary(progress: ReplyProgress): { requester: string | null; target: string | null; + detail: string | null; excerpt: string | null; } { const text = progress.text ?? ""; @@ -2411,6 +2442,7 @@ function delegationSummary(progress: ReplyProgress): { return { requester, target, + detail: task ?? fallback, excerpt: task ? previewText(task, 92) : fallback @@ -2425,6 +2457,102 @@ const fourLineClamp = { WebkitLineClamp: 4, } as const; +function useIsTextTruncated( + ref: RefObject, + content: string, + expanded: boolean, +) { + const [truncated, setTruncated] = useState(false); + + useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + + const measure = () => { + const previousDisplay = element.style.display; + const previousBoxOrient = element.style.webkitBoxOrient; + const previousLineClamp = element.style.webkitLineClamp; + + if (expanded) { + element.style.display = "-webkit-box"; + element.style.webkitBoxOrient = "vertical"; + element.style.webkitLineClamp = "4"; + } + + setTruncated(element.scrollHeight > element.clientHeight + 1); + + if (expanded) { + element.style.display = previousDisplay; + element.style.webkitBoxOrient = previousBoxOrient; + element.style.webkitLineClamp = previousLineClamp; + } + }; + + measure(); + const frame = window.requestAnimationFrame(measure); + window.addEventListener("resize", measure); + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); + observer?.observe(element); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener("resize", measure); + observer?.disconnect(); + }; + }, [content, expanded, ref]); + + return truncated; +} + +function ExpandableProgressDetail({ + content, + className, + expanded, + onToggle, +}: { + content: string; + className?: string; + expanded: boolean; + onToggle: () => void; +}) { + const contentId = useId(); + const detailRef = useRef(null); + const truncated = useIsTextTruncated(detailRef, content, expanded); + const showToggle = truncated; + + return ( +
+

+ {content} +

+ {showToggle && ( + + )} +
+ ); +} + function TurnMarker({ progress }: { progress: ReplyProgress }) { const stage = progressStageLabel(progress.stage); const label = progress.label.trim() || "Tour"; @@ -2445,14 +2573,21 @@ function TurnMarker({ progress }: { progress: ReplyProgress }) { function ToolProgressCard({ progress, requesterName, + progressKey, + expanded, + onToggleExpanded, }: { progress: ReplyProgress; requesterName: string; + progressKey: string; + expanded: boolean; + onToggleExpanded: () => void; }) { const stage = progressStageLabel(progress.stage); - const detail = - progress.text && !isDelegationProgress(progress) ? previewText(progress.text) : null; const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null; + const detail = delegation + ? delegation.detail + : progress.text?.trim() || null; const title = delegation ? delegation.target ? `${delegation.requester ?? requesterName} ${ @@ -2475,7 +2610,7 @@ function ToolProgressCard({ )} ยท {stage} - {delegation?.excerpt && ( + {delegation?.excerpt && !detail && (

{delegation.excerpt}

@@ -2494,12 +2629,13 @@ function ToolProgressCard({ )} {detail && ( -

- {detail} -

+ )} @@ -2509,9 +2645,15 @@ function ToolProgressCard({ function ProgressBubble({ progress, requesterName, + progressKey, + expanded, + onToggleExpanded, }: { progress: ReplyProgress; requesterName: string; + progressKey: string; + expanded: boolean; + onToggleExpanded: () => void; }) { if (isTimelineProgress(progress)) return ; if (isCenteredToolProgress(progress)) { @@ -2519,13 +2661,16 @@ function ProgressBubble({ ); } const title = progressTitle(progress); const stage = progressStageLabel(progress.stage); const source = progressSourceLabel(progress); - const detail = progress.text ? previewText(progress.text, 180) : null; + const detail = progress.text?.trim() || null; return (
{detail && ( -

- {detail} -

+ )}