Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/app/src/components/explainer-cards.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { ExplainerCardsPortal } from "./explainer-cards";

function welcomeScreen() {
const welcome = document.createElement("div");
welcome.setAttribute("data-testid", "copilot-welcome-screen");
const content = document.createElement("div");
content.appendChild(document.createElement("div"));
welcome.appendChild(content);
return welcome;
}

afterEach(() => {
cleanup();
document.body.innerHTML = "";
vi.restoreAllMocks();
});

describe("ExplainerCardsPortal", () => {
it("watches the chat's direct children and follows welcome screen changes", async () => {
const chat = document.createElement("div");
chat.setAttribute("data-testid", "copilot-chat");
const firstWelcome = welcomeScreen();
chat.appendChild(firstWelcome);
document.body.appendChild(chat);

const observe = vi.spyOn(MutationObserver.prototype, "observe");
render(<ExplainerCardsPortal />);

expect(observe).toHaveBeenCalledWith(chat, { childList: true });
expect(screen.getByText("Generative UI")).toBeInTheDocument();

const originalPortal = document.getElementById("explainer-cards-portal");
originalPortal?.remove();
await waitFor(() => {
expect(document.getElementById("explainer-cards-portal")).not.toBe(
originalPortal
);
expect(screen.getByText("Generative UI")).toBeInTheDocument();
});

firstWelcome.remove();
await waitFor(() => {
expect(screen.queryByText("Generative UI")).not.toBeInTheDocument();
});

chat.appendChild(welcomeScreen());
await waitFor(() => {
expect(screen.getByText("Generative UI")).toBeInTheDocument();
});
});
});
60 changes: 37 additions & 23 deletions apps/app/src/components/explainer-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,28 +86,49 @@ export function ExplainerCardsPortal() {
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);

useEffect(() => {
const chat = document.querySelector<HTMLElement>('[data-testid="copilot-chat"]');
if (!chat) return;

const WELCOME_SELECTOR = '[data-testid="copilot-welcome-screen"]';
const PORTAL_ID = "explainer-cards-portal";
let observedContent: HTMLElement | null = null;
let contentObserver: MutationObserver | null = null;

const tryAttach = () => {
const welcomeScreen = document.querySelector<HTMLElement>(WELCOME_SELECTOR);
const welcomeScreen = chat.querySelector<HTMLElement>(WELCOME_SELECTOR);
if (!welcomeScreen) {
contentObserver?.disconnect();
contentObserver = null;
observedContent = null;
setPortalTarget(null);
return;
}

// Insert portal container inside the welcome screen's main content div,
// before the suggestions row
const mainContent = welcomeScreen.children[0] as HTMLElement | undefined;
if (!mainContent) {
contentObserver?.disconnect();
contentObserver = null;
observedContent = null;
setPortalTarget(null);
return;
}

// Reuse existing portal container if present
let portal = document.getElementById(PORTAL_ID);
if (mainContent !== observedContent) {
contentObserver?.disconnect();
observedContent = mainContent;
contentObserver = new MutationObserver(tryAttach);
contentObserver.observe(mainContent, { childList: true });
}

// Reuse the container if CopilotKit kept it during a welcome update.
let portal = mainContent.querySelector<HTMLElement>(`#${PORTAL_ID}`);
if (portal) {
setPortalTarget(portal);
return;
}

// Insert portal container inside the welcome screen's main content div,
// before the suggestions row
const mainContent = welcomeScreen.children[0] as HTMLElement | undefined;
if (!mainContent) return;

portal = document.createElement("div");
portal.id = PORTAL_ID;
portal.style.width = "100%";
Expand All @@ -125,21 +146,14 @@ export function ExplainerCardsPortal() {

tryAttach();

const observer = new MutationObserver(() => {
const welcomeScreen = document.querySelector<HTMLElement>(WELCOME_SELECTOR);
if (!welcomeScreen) {
// Welcome screen removed (chat started) — clean up
const stale = document.getElementById(PORTAL_ID);
if (stale) stale.remove();
setPortalTarget(null);
} else if (!document.getElementById(PORTAL_ID)) {
// Welcome screen appeared but no portal yet
tryAttach();
}
});

observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
// The welcome screen is a direct child of the chat. Streaming updates
// happen deeper in the message tree and do not need to wake this observer.
const chatObserver = new MutationObserver(tryAttach);
chatObserver.observe(chat, { childList: true });
return () => {
chatObserver.disconnect();
contentObserver?.disconnect();
};
}, []);

if (!portalTarget) return null;
Expand Down