feat(web): messages progress et usages MCP dépliables/repliables, repliés par défaut — #233
This commit is contained in:
@ -82,6 +82,21 @@ function expectedChatTime(ms: number): string {
|
|||||||
return `${hour}:${minute}`;
|
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", () => {
|
describe("CustomAgentChatView", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -1493,6 +1508,127 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours");
|
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<void>((resolve) => {
|
||||||
|
emitChunk = (chunk: unknown) => {
|
||||||
|
onChunk(chunk);
|
||||||
|
if ((chunk as { kind?: string }).kind === "final") resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId="chat-session-1"
|
||||||
|
conversationId="conversation-1"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it("renders progress immediately when the chunk callback fires", async () => {
|
||||||
let emitChunk: ((chunk: unknown) => void) | null = null;
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
const agent = {
|
const agent = {
|
||||||
|
|||||||
@ -9,12 +9,15 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ClipboardEvent,
|
type ClipboardEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -767,6 +770,9 @@ export function CustomAgentChatView({
|
|||||||
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map())
|
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map())
|
||||||
: new Map(),
|
: new Map(),
|
||||||
);
|
);
|
||||||
|
const [expandedProgressKeys, setExpandedProgressKeys] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@ -1988,7 +1994,23 @@ export function CustomAgentChatView({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
displayTurns.map((turn, index) => (
|
displayTurns.map((turn, index) => (
|
||||||
<ChatBubble key={index} turn={turn} requesterName={agentName} />
|
<ChatBubble
|
||||||
|
key={index}
|
||||||
|
turn={turn}
|
||||||
|
requesterName={agentName}
|
||||||
|
expandedProgressKeys={expandedProgressKeys}
|
||||||
|
onToggleProgressExpanded={(key) =>
|
||||||
|
setExpandedProgressKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -2166,9 +2188,13 @@ export function CustomAgentChatView({
|
|||||||
function ChatBubble({
|
function ChatBubble({
|
||||||
turn,
|
turn,
|
||||||
requesterName,
|
requesterName,
|
||||||
|
expandedProgressKeys,
|
||||||
|
onToggleProgressExpanded,
|
||||||
}: {
|
}: {
|
||||||
turn: ChatTurn;
|
turn: ChatTurn;
|
||||||
requesterName: string;
|
requesterName: string;
|
||||||
|
expandedProgressKeys: Set<string>;
|
||||||
|
onToggleProgressExpanded: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (turn.role === "tool") {
|
if (turn.role === "tool") {
|
||||||
return (
|
return (
|
||||||
@ -2180,10 +2206,14 @@ function ChatBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (turn.role === "progress") {
|
if (turn.role === "progress") {
|
||||||
|
const key = progressUpsertKey(turn.progress);
|
||||||
return (
|
return (
|
||||||
<ProgressBubble
|
<ProgressBubble
|
||||||
progress={turn.progress}
|
progress={turn.progress}
|
||||||
requesterName={requesterName}
|
requesterName={requesterName}
|
||||||
|
progressKey={key}
|
||||||
|
expanded={expandedProgressKeys.has(key)}
|
||||||
|
onToggleExpanded={() => onToggleProgressExpanded(key)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2401,6 +2431,7 @@ function parseKeyValue(text: string, key: string): string | null {
|
|||||||
function delegationSummary(progress: ReplyProgress): {
|
function delegationSummary(progress: ReplyProgress): {
|
||||||
requester: string | null;
|
requester: string | null;
|
||||||
target: string | null;
|
target: string | null;
|
||||||
|
detail: string | null;
|
||||||
excerpt: string | null;
|
excerpt: string | null;
|
||||||
} {
|
} {
|
||||||
const text = progress.text ?? "";
|
const text = progress.text ?? "";
|
||||||
@ -2411,6 +2442,7 @@ function delegationSummary(progress: ReplyProgress): {
|
|||||||
return {
|
return {
|
||||||
requester,
|
requester,
|
||||||
target,
|
target,
|
||||||
|
detail: task ?? fallback,
|
||||||
excerpt: task
|
excerpt: task
|
||||||
? previewText(task, 92)
|
? previewText(task, 92)
|
||||||
: fallback
|
: fallback
|
||||||
@ -2425,6 +2457,102 @@ const fourLineClamp = {
|
|||||||
WebkitLineClamp: 4,
|
WebkitLineClamp: 4,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function useIsTextTruncated(
|
||||||
|
ref: RefObject<HTMLElement>,
|
||||||
|
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<HTMLParagraphElement>(null);
|
||||||
|
const truncated = useIsTextTruncated(detailRef, content, expanded);
|
||||||
|
const showToggle = truncated;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1 min-w-0">
|
||||||
|
<p
|
||||||
|
id={contentId}
|
||||||
|
ref={detailRef}
|
||||||
|
data-testid="chat-progress-detail"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden whitespace-pre-wrap break-words text-[11px] text-content",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={expanded ? undefined : fourLineClamp}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</p>
|
||||||
|
{showToggle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"mt-1 text-[11px] font-medium text-primary underline-offset-2 hover:underline",
|
||||||
|
className?.includes("text-center") && "mx-auto block",
|
||||||
|
)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={contentId}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
{expanded ? "Afficher moins" : "Afficher plus"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TurnMarker({ progress }: { progress: ReplyProgress }) {
|
function TurnMarker({ progress }: { progress: ReplyProgress }) {
|
||||||
const stage = progressStageLabel(progress.stage);
|
const stage = progressStageLabel(progress.stage);
|
||||||
const label = progress.label.trim() || "Tour";
|
const label = progress.label.trim() || "Tour";
|
||||||
@ -2445,14 +2573,21 @@ function TurnMarker({ progress }: { progress: ReplyProgress }) {
|
|||||||
function ToolProgressCard({
|
function ToolProgressCard({
|
||||||
progress,
|
progress,
|
||||||
requesterName,
|
requesterName,
|
||||||
|
progressKey,
|
||||||
|
expanded,
|
||||||
|
onToggleExpanded,
|
||||||
}: {
|
}: {
|
||||||
progress: ReplyProgress;
|
progress: ReplyProgress;
|
||||||
requesterName: string;
|
requesterName: string;
|
||||||
|
progressKey: string;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
}) {
|
}) {
|
||||||
const stage = progressStageLabel(progress.stage);
|
const stage = progressStageLabel(progress.stage);
|
||||||
const detail =
|
|
||||||
progress.text && !isDelegationProgress(progress) ? previewText(progress.text) : null;
|
|
||||||
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
|
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
|
||||||
|
const detail = delegation
|
||||||
|
? delegation.detail
|
||||||
|
: progress.text?.trim() || null;
|
||||||
const title = delegation
|
const title = delegation
|
||||||
? delegation.target
|
? delegation.target
|
||||||
? `${delegation.requester ?? requesterName} ${
|
? `${delegation.requester ?? requesterName} ${
|
||||||
@ -2475,7 +2610,7 @@ function ToolProgressCard({
|
|||||||
)}
|
)}
|
||||||
<span className="shrink-0 text-muted">· {stage}</span>
|
<span className="shrink-0 text-muted">· {stage}</span>
|
||||||
</div>
|
</div>
|
||||||
{delegation?.excerpt && (
|
{delegation?.excerpt && !detail && (
|
||||||
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
|
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
|
||||||
{delegation.excerpt}
|
{delegation.excerpt}
|
||||||
</p>
|
</p>
|
||||||
@ -2494,12 +2629,13 @@ function ToolProgressCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detail && (
|
{detail && (
|
||||||
<p
|
<ExpandableProgressDetail
|
||||||
className="mt-1 overflow-hidden whitespace-pre-wrap break-words text-center text-[11px] text-content"
|
key={progressKey}
|
||||||
style={fourLineClamp}
|
content={detail}
|
||||||
>
|
className="text-center"
|
||||||
{detail}
|
expanded={expanded}
|
||||||
</p>
|
onToggle={onToggleExpanded}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2509,9 +2645,15 @@ function ToolProgressCard({
|
|||||||
function ProgressBubble({
|
function ProgressBubble({
|
||||||
progress,
|
progress,
|
||||||
requesterName,
|
requesterName,
|
||||||
|
progressKey,
|
||||||
|
expanded,
|
||||||
|
onToggleExpanded,
|
||||||
}: {
|
}: {
|
||||||
progress: ReplyProgress;
|
progress: ReplyProgress;
|
||||||
requesterName: string;
|
requesterName: string;
|
||||||
|
progressKey: string;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
|
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
|
||||||
if (isCenteredToolProgress(progress)) {
|
if (isCenteredToolProgress(progress)) {
|
||||||
@ -2519,13 +2661,16 @@ function ProgressBubble({
|
|||||||
<ToolProgressCard
|
<ToolProgressCard
|
||||||
progress={progress}
|
progress={progress}
|
||||||
requesterName={requesterName}
|
requesterName={requesterName}
|
||||||
|
progressKey={progressKey}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpanded={onToggleExpanded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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 ? previewText(progress.text, 180) : null;
|
const detail = progress.text?.trim() || null;
|
||||||
return (
|
return (
|
||||||
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
|
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
|
||||||
<div
|
<div
|
||||||
@ -2546,12 +2691,12 @@ function ProgressBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{detail && (
|
{detail && (
|
||||||
<p
|
<ExpandableProgressDetail
|
||||||
className="mt-1 overflow-hidden whitespace-pre-wrap break-words text-content"
|
key={progressKey}
|
||||||
style={fourLineClamp}
|
content={detail}
|
||||||
>
|
expanded={expanded}
|
||||||
{detail}
|
onToggle={onToggleExpanded}
|
||||||
</p>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user