fix(canvas): AgentCommsPanel display + initial-state parity with my-chat
User-visible problem: agent-comms panel opens mid-conversation on long histories (the same chat-opens-in-middle bug PR #2903 fixed for my-chat) and silently renders empty state when the history fetch fails (no retry button, no diagnostic). Three changes mirror the my-chat patterns from ChatTab: 1. Initial-mount instant scroll. Adds hasInitialScrollRef + switches the scroll hook from useEffect to useLayoutEffect. First arrival of messages → scrollIntoView `instant`; subsequent appends → `smooth` as before. useLayoutEffect runs before paint so the user never sees the panel jump for one frame on every append. 2. Error UI with Retry button. Adds `loadError` state. The history-load .catch now sets the error message; a new branch in the render renders a red alert with the failure text and a Retry button that re-invokes `loadInitial`. Same shape as ChatTab MyChatPanel's `loadError` handling — both surfaces should fail loud, not silent. 3. Extracted `loadInitial` callback. The history-load body becomes a useCallback so the retry button has a stable reference to call. Mirrors ChatTab's loadInitial. Tests (4 new in AgentCommsPanel.render.test.tsx): - Loading state renders the loading copy. - Error state with Retry button renders on rejection; clicking Retry fires a second api.get. - Empty state renders when load succeeds with zero rows. - scrollIntoView is called with behavior=instant on first message arrival (pins the chat-opens-in-middle prevention). Verification: - pnpm test → 1284/1284 pass (1280 prior + 4 new) - tsc --noEmit → clean - 92 → 93 test files, no existing test broken Closes the parity gap raised in chat. The two surfaces now share: loading copy / error UI / empty-state placeholder / scroll behaviour / useLayoutEffect timing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ff75aeb43e
commit
5ad2669f88
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@ -184,13 +184,23 @@ function unwrapErrorText(raw: string | null): string {
|
|||||||
export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
||||||
const [messages, setMessages] = useState<CommMessage[]>([]);
|
const [messages, setMessages] = useState<CommMessage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
// Dedup by timestamp+type+peer to handle API load + WebSocket race
|
// Dedup by timestamp+type+peer to handle API load + WebSocket race
|
||||||
const seenKeys = useRef(new Set<string>());
|
const seenKeys = useRef(new Set<string>());
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(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
|
// Load history. Extracted so the error-state retry button can
|
||||||
useEffect(() => {
|
// re-invoke without remount. ChatTab uses the same shape
|
||||||
|
// (loadInitial → loadError state → retry button).
|
||||||
|
const loadInitial = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
seenKeys.current.clear();
|
||||||
api.get<ActivityEntry[]>(`/workspaces/${workspaceId}/activity?source=agent&limit=50`)
|
api.get<ActivityEntry[]>(`/workspaces/${workspaceId}/activity?source=agent&limit=50`)
|
||||||
.then((entries) => {
|
.then((entries) => {
|
||||||
const filtered = (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
|
// the .then body) — the panel just sat on the empty state
|
||||||
// with zero signal.
|
// with zero signal.
|
||||||
console.warn("AgentCommsPanel: load activity failed", err);
|
console.warn("AgentCommsPanel: load activity failed", err);
|
||||||
|
setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [workspaceId]);
|
}, [workspaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitial();
|
||||||
|
}, [loadInitial]);
|
||||||
|
|
||||||
// Live updates routed through the global ReconnectingSocket. The
|
// Live updates routed through the global ReconnectingSocket. The
|
||||||
// previous pattern of `new WebSocket(WS_URL)` per panel had no
|
// previous pattern of `new WebSocket(WS_URL)` per panel had no
|
||||||
// onclose / no reconnect, so any drop (idle timeout, browser
|
// onclose / no reconnect, so any drop (idle timeout, browser
|
||||||
@ -358,7 +373,18 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
|||||||
} catch { /* ignore */ }
|
} 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" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
@ -366,6 +392,27 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
|||||||
return <div className="text-xs text-ink-soft text-center py-8">Loading agent communications...</div>;
|
return <div className="text-xs text-ink-soft text-center py-8">Loading agent communications...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<p className="text-[11px] text-bad mb-1.5">
|
||||||
|
Failed to load agent communications: {loadError}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={loadInitial}
|
||||||
|
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-xs text-ink-soft text-center py-8">
|
<div className="text-xs text-ink-soft text-center py-8">
|
||||||
|
|||||||
@ -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<unknown>>();
|
||||||
|
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(<AgentCommsPanel workspaceId="ws-self" />);
|
||||||
|
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(<AgentCommsPanel workspaceId="ws-self" />);
|
||||||
|
|
||||||
|
// 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(<AgentCommsPanel workspaceId="ws-self" />);
|
||||||
|
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(<AgentCommsPanel workspaceId="ws-self" />);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user