feat(site): harden Agents embed frame communication and add theme sync (#23574)

Add theme synchronization, navigation blocking, scroll-to-bottom
handling, and chat-ready signaling to the agent embed page. The parent
frame can now set light/dark theme via postMessage or query param, and
ThemeProvider skips its own class manipulation when the embed marker is
present. Navigation attempts that leave the embed route are intercepted
and forwarded to the parent frame. The scroll container ref is lifted
to the layout so the parent can request scroll-to-bottom.
This commit is contained in:
Ehab Younes
2026-03-26 18:03:30 +03:00
committed by GitHub
parent 81fe7543b4
commit 249ef7c567
5 changed files with 141 additions and 5 deletions
+7 -1
View File
@@ -69,6 +69,10 @@ export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
useEffect(() => {
const root = document.documentElement;
// Embedded pages manage theme independently.
if (root.dataset.embedTheme) {
return;
}
if (themePreference === "auto") {
root.classList.add(preferredColorScheme);
} else {
@@ -76,7 +80,9 @@ export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
}
return () => {
root.classList.remove("light", "dark");
if (!root.dataset.embedTheme) {
root.classList.remove("light", "dark");
}
};
}, [themePreference, preferredColorScheme]);
@@ -11,6 +11,7 @@ import {
} from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { useRef } from "react";
import { Outlet } from "react-router";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import {
@@ -36,6 +37,7 @@ import type { AgentsOutletContext } from "./AgentsPage";
// Layout wrapper provides outlet context for the child route.
// ---------------------------------------------------------------------------
const AgentDetailLayout: FC = () => {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
return (
<div className="flex h-full">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -54,6 +56,8 @@ const AgentDetailLayout: FC = () => {
isSidebarCollapsed: false,
onToggleSidebarCollapsed: () => {},
onExpandSidebar: () => {},
onChatReady: () => {},
scrollContainerRef,
} satisfies AgentsOutletContext
}
/>
+12 -1
View File
@@ -294,13 +294,14 @@ const AgentDetail: FC = () => {
requestUnarchiveAgent,
isSidebarCollapsed,
onToggleSidebarCollapsed,
onChatReady,
scrollContainerRef,
} = useOutletContext<AgentsOutletContext>();
const queryClient = useQueryClient();
const [selectedModel, setSelectedModel] = useState("");
const [pendingEditMessageId, setPendingEditMessageId] = useState<
number | null
>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
const inputValueRef = useRef(
agentId
@@ -879,6 +880,16 @@ const AgentDetail: FC = () => {
requestUnarchiveAgent(agentId);
};
// Signal the parent layout that messages have loaded.
const chatReadyFiredRef = useRef<string | null>(null);
useEffect(() => {
if (chatReadyFiredRef.current === agentId || !chatMessagesQuery.isSuccess) {
return;
}
chatReadyFiredRef.current = agentId ?? null;
onChatReady();
}, [onChatReady, chatMessagesQuery.isSuccess, agentId]);
if (chatQuery.isLoading || chatMessagesQuery.isLoading) {
return (
<AgentDetailLoadingView
+110 -2
View File
@@ -2,9 +2,9 @@ import { useAuthContext } from "contexts/auth/AuthProvider";
import { ProxyProvider } from "contexts/ProxyContext";
import { DashboardProvider } from "modules/dashboard/DashboardProvider";
import { permissionChecks } from "modules/permissions";
import { type FC, useEffect, useRef, useState } from "react";
import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { Outlet, useParams } from "react-router";
import { Outlet, useBlocker, useParams, useSearchParams } from "react-router";
import { getErrorMessage } from "#/api/errors";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
@@ -48,6 +48,39 @@ const getBootstrapToken = (data: unknown): string | undefined => {
return token.length > 0 ? token : undefined;
};
const getThemeFromMessage = (data: unknown): "light" | "dark" | undefined => {
if (typeof data !== "object" || data === null) {
return undefined;
}
const msg = data as { type?: unknown; payload?: unknown };
if (msg.type !== "coder:set-theme") {
return undefined;
}
if (typeof msg.payload !== "object" || msg.payload === null) {
return undefined;
}
const payload = msg.payload as { theme?: unknown };
if (payload.theme !== "light" && payload.theme !== "dark") {
return undefined;
}
return payload.theme;
};
/**
* Sets the embed theme on <html> and marks it with a data
* attribute so ThemeProvider skips its own class manipulation.
* No-ops when the requested theme is already active.
*/
const applyEmbedTheme = (theme: "light" | "dark") => {
const root = document.documentElement;
if (root.dataset.embedTheme === theme) {
return;
}
root.classList.remove("light", "dark");
root.classList.add(theme);
root.dataset.embedTheme = theme;
};
const AgentEmbedPage: FC = () => {
const { agentId } = useParams<{ agentId: string }>();
if (!agentId) {
@@ -118,6 +151,78 @@ const AgentEmbedPage: FC = () => {
setIsSidebarCollapsed((current) => !current);
};
// Block navigations that leave the embed route and forward
// the target URL to the parent frame.
useBlocker(({ nextLocation }) => {
if (nextLocation.pathname.startsWith(`/agents/${agentId}/embed`)) {
return false;
}
window.parent.postMessage(
{
type: "coder:navigate",
payload: {
url: nextLocation.pathname + nextLocation.search + nextLocation.hash,
},
},
"*",
);
return true;
});
// Apply the initial theme from the URL query param
// (?theme=light|dark) or fall back to prefers-color-scheme.
// useLayoutEffect runs before paint to prevent a flash.
const [searchParams] = useSearchParams();
useLayoutEffect(() => {
const paramTheme = searchParams.get("theme");
if (paramTheme === "light" || paramTheme === "dark") {
applyEmbedTheme(paramTheme);
} else {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
applyEmbedTheme(prefersDark ? "dark" : "light");
}
return () => {
document.documentElement.classList.remove("light", "dark");
delete document.documentElement.dataset.embedTheme;
};
}, [searchParams]);
// Shared ref for the chat scroll container. Passed through the
// outlet context so AgentDetail attaches it to the DOM element
// instead of creating its own.
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// Listen for parent frame commands: theme changes and
// scroll-to-bottom requests.
useEffect(() => {
const parentWindow = window.parent;
const handler = (event: MessageEvent) => {
if (event.source !== parentWindow) {
return;
}
const theme = getThemeFromMessage(event.data);
if (theme) {
applyEmbedTheme(theme);
return;
}
if (event.data?.type === "coder:scroll-to-bottom") {
// flex-col-reverse: scrollTop 0 is the visual bottom.
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
const onChatReady = () => {
window.parent.postMessage({ type: "coder:chat-ready" }, "*");
};
const outletContext: AgentsOutletContext = {
chatErrorReasons,
setChatErrorReason,
@@ -128,7 +233,10 @@ const AgentEmbedPage: FC = () => {
isSidebarCollapsed,
onToggleSidebarCollapsed,
onExpandSidebar: () => {},
onChatReady,
scrollContainerRef,
};
// When signed out and not already bootstrapping, listen for the
// postMessage from the parent frame carrying the session token.
const isAwaitingBootstrapMessage =
+8 -1
View File
@@ -1,4 +1,4 @@
import type { FC } from "react";
import { type FC, type RefObject, useRef } from "react";
import { Outlet, useLocation } from "react-router";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
@@ -23,6 +23,9 @@ export interface AgentsOutletContext {
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
onExpandSidebar: () => void;
onChatReady: () => void;
/** Ref attached to the chat scroll container by AgentDetail. */
scrollContainerRef: RefObject<HTMLDivElement | null>;
}
interface AgentsPageViewProps {
@@ -109,6 +112,8 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
]),
);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const outletContextValue: AgentsOutletContext = {
chatErrorReasons,
setChatErrorReason,
@@ -119,6 +124,8 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
isSidebarCollapsed,
onToggleSidebarCollapsed,
onExpandSidebar,
onChatReady: () => {},
scrollContainerRef,
};
return (