diff --git a/apps/app/src/components/explainer-cards.test.tsx b/apps/app/src/components/explainer-cards.test.tsx
new file mode 100644
index 0000000..348ae15
--- /dev/null
+++ b/apps/app/src/components/explainer-cards.test.tsx
@@ -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();
+
+ 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();
+ });
+ });
+});
diff --git a/apps/app/src/components/explainer-cards.tsx b/apps/app/src/components/explainer-cards.tsx
index 0b4e970..f4003a5 100644
--- a/apps/app/src/components/explainer-cards.tsx
+++ b/apps/app/src/components/explainer-cards.tsx
@@ -86,28 +86,49 @@ export function ExplainerCardsPortal() {
const [portalTarget, setPortalTarget] = useState(null);
useEffect(() => {
+ const chat = document.querySelector('[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(WELCOME_SELECTOR);
+ const welcomeScreen = chat.querySelector(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(`#${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%";
@@ -125,21 +146,14 @@ export function ExplainerCardsPortal() {
tryAttach();
- const observer = new MutationObserver(() => {
- const welcomeScreen = document.querySelector(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;