diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx index fc327ea0..074d96fc 100644 --- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx +++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useRef } from "react"; +import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { api } from "@/lib/api"; @@ -184,13 +184,23 @@ function unwrapErrorText(raw: string | null): string { export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); // Dedup by timestamp+type+peer to handle API load + WebSocket race const seenKeys = useRef(new Set()); const bottomRef = useRef(null); + // Mirrors the my-chat scroll behaviour from ChatTab (PR #2903) — + // smooth-scroll on a long history gets interrupted by concurrent + // renders and lands the panel mid-conversation. Switch the first + // arrival to instant; subsequent appends animate. + const hasInitialScrollRef = useRef(false); - // Load history - useEffect(() => { + // Load history. Extracted so the error-state retry button can + // re-invoke without remount. ChatTab uses the same shape + // (loadInitial → loadError state → retry button). + const loadInitial = useCallback(() => { setLoading(true); + setLoadError(null); + seenKeys.current.clear(); api.get(`/workspaces/${workspaceId}/activity?source=agent&limit=50`) .then((entries) => { const filtered = (entries ?? []) @@ -234,10 +244,15 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { // the .then body) — the panel just sat on the empty state // with zero signal. console.warn("AgentCommsPanel: load activity failed", err); + setLoadError(err instanceof Error ? err.message : String(err)); setLoading(false); }); }, [workspaceId]); + useEffect(() => { + loadInitial(); + }, [loadInitial]); + // Live updates routed through the global ReconnectingSocket. The // previous pattern of `new WebSocket(WS_URL)` per panel had no // onclose / no reconnect, so any drop (idle timeout, browser @@ -358,7 +373,18 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { } catch { /* ignore */ } }); - useEffect(() => { + // useLayoutEffect (not useEffect) so the scroll runs BEFORE paint — + // otherwise the user sees the panel jump for one frame on every + // append. Mirrors ChatTab's MyChatPanel scroll block. + useLayoutEffect(() => { + if (!hasInitialScrollRef.current && messages.length > 0) { + // Instant on first arrival — smooth-scroll on a long history + // gets interrupted by concurrent renders and lands the panel + // mid-conversation (the chat-opens-in-middle bug class). + hasInitialScrollRef.current = true; + bottomRef.current?.scrollIntoView({ behavior: "instant" as ScrollBehavior }); + return; + } bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); @@ -366,6 +392,27 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { return
Loading agent communications...
; } + if (loadError !== null && messages.length === 0) { + // Mirrors ChatTab my-chat error UI — surfaces the load failure + // with a retry button instead of silently rendering empty state. + return ( +
+

+ Failed to load agent communications: {loadError} +

+ +
+ ); + } + if (messages.length === 0) { return (
diff --git a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx new file mode 100644 index 00000000..80b37982 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.render.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +// API mock — tests can override per case via apiGetMock.mockImplementationOnce. +const apiGetMock = vi.fn<(url: string) => Promise>(); +vi.mock("@/lib/api", () => ({ + api: { + get: (url: string) => apiGetMock(url), + }, +})); + +// useSocketEvent — no-op for these render tests; live updates aren't +// what we're verifying here. +vi.mock("@/hooks/useSocketEvent", () => ({ + useSocketEvent: () => {}, +})); + +// Canvas store — peer name resolution. +vi.mock("@/store/canvas", () => ({ + useCanvasStore: { + getState: () => ({ + nodes: [ + { id: "ws-self", data: { name: "Self" } }, + { id: "ws-peer", data: { name: "Peer Agent" } }, + ], + }), + }, +})); + +// Toaster shim — AgentCommsPanel imports showToast. +vi.mock("../../Toaster", () => ({ + showToast: vi.fn(), +})); + +import { AgentCommsPanel } from "../AgentCommsPanel"; + +// jsdom doesn't implement scrollIntoView. Tests that observe the call +// install a spy here; tests that don't care still need a no-op stub +// so the component doesn't throw. +const scrollSpy = vi.fn<(opts?: ScrollIntoViewOptions | boolean) => void>(); +beforeEach(() => { + apiGetMock.mockReset(); + scrollSpy.mockReset(); + Element.prototype.scrollIntoView = scrollSpy as unknown as Element["scrollIntoView"]; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("AgentCommsPanel — initial-state parity with ChatTab my-chat", () => { + it("shows loading text while history fetch is in flight", () => { + apiGetMock.mockReturnValueOnce(new Promise(() => { /* never resolves */ })); + render(); + expect(screen.getByText("Loading agent communications...")).toBeDefined(); + }); + + it("renders error UI with a Retry button when the history fetch rejects", async () => { + apiGetMock.mockRejectedValueOnce(new Error("network down")); + render(); + + // Wait for the error state to render — loading→error transition is async. + const alert = await waitFor(() => screen.getByRole("alert")); + expect(alert.textContent).toMatch(/Failed to load agent communications/); + expect(alert.textContent).toMatch(/network down/); + + // Retry button must be present and trigger a refetch. + const retry = screen.getByRole("button", { name: "Retry" }); + apiGetMock.mockResolvedValueOnce([]); // success on retry + fireEvent.click(retry); + + // Two calls total: initial load + retry. Pin via mock call count. + await waitFor(() => expect(apiGetMock.mock.calls.length).toBe(2)); + }); + + it("falls back to empty-state copy when load succeeds with zero rows", async () => { + apiGetMock.mockResolvedValueOnce([]); + render(); + await waitFor(() => + expect(screen.getByText("No agent-to-agent communications yet.")).toBeDefined(), + ); + }); + + it("scrollIntoView is called with behavior=instant on the first message arrival", async () => { + apiGetMock.mockResolvedValueOnce([ + { + id: "act-1", + activity_type: "a2a_send", + source_id: "ws-self", + target_id: "ws-peer", + method: "message/send", + summary: "Delegating", + request_body: { message: { parts: [{ text: "hi" }] } }, + response_body: null, + status: "ok", + created_at: "2026-04-25T18:00:00Z", + }, + ]); + render(); + + // useLayoutEffect is what makes the first call instant — wait for + // the panel to render at least one message. + await waitFor(() => expect(scrollSpy.mock.calls.length).toBeGreaterThan(0)); + + // The pinned contract: SOME call uses behavior: "instant" — the + // first-arrival case. Subsequent appends use "smooth", but those + // can't fire here (no live update yet). + const sawInstant = scrollSpy.mock.calls.some((args) => { + const opts = args[0]; + return typeof opts === "object" && opts !== null && "behavior" in opts && opts.behavior === "instant"; + }); + expect(sawInstant).toBe(true); + }); +});