From 249ef7c567ddf1edb53683f264fe21317e349efe Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 26 Mar 2026 15:03:30 +0000 Subject: [PATCH] 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. --- site/src/contexts/ThemeProvider.tsx | 8 +- .../pages/AgentsPage/AgentDetail.stories.tsx | 4 + site/src/pages/AgentsPage/AgentDetail.tsx | 13 +- site/src/pages/AgentsPage/AgentEmbedPage.tsx | 112 +++++++++++++++++- site/src/pages/AgentsPage/AgentsPageView.tsx | 9 +- 5 files changed, 141 insertions(+), 5 deletions(-) diff --git a/site/src/contexts/ThemeProvider.tsx b/site/src/contexts/ThemeProvider.tsx index 2090ce6fcb..dcbaa018af 100644 --- a/site/src/contexts/ThemeProvider.tsx +++ b/site/src/contexts/ThemeProvider.tsx @@ -69,6 +69,10 @@ export const ThemeProvider: FC = ({ 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 = ({ children }) => { } return () => { - root.classList.remove("light", "dark"); + if (!root.dataset.embedTheme) { + root.classList.remove("light", "dark"); + } }; }, [themePreference, preferredColorScheme]); diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 966c2dbdf2..e05ff1b69d 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -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(null); return (
@@ -54,6 +56,8 @@ const AgentDetailLayout: FC = () => { isSidebarCollapsed: false, onToggleSidebarCollapsed: () => {}, onExpandSidebar: () => {}, + onChatReady: () => {}, + scrollContainerRef, } satisfies AgentsOutletContext } /> diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index fa91b6301c..063d1da49e 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -294,13 +294,14 @@ const AgentDetail: FC = () => { requestUnarchiveAgent, isSidebarCollapsed, onToggleSidebarCollapsed, + onChatReady, + scrollContainerRef, } = useOutletContext(); const queryClient = useQueryClient(); const [selectedModel, setSelectedModel] = useState(""); const [pendingEditMessageId, setPendingEditMessageId] = useState< number | null >(null); - const scrollContainerRef = useRef(null); const chatInputRef = useRef(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(null); + useEffect(() => { + if (chatReadyFiredRef.current === agentId || !chatMessagesQuery.isSuccess) { + return; + } + chatReadyFiredRef.current = agentId ?? null; + onChatReady(); + }, [onChatReady, chatMessagesQuery.isSuccess, agentId]); + if (chatQuery.isLoading || chatMessagesQuery.isLoading) { return ( { 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 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(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 = diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 76e44045da..519db48223 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -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; } interface AgentsPageViewProps { @@ -109,6 +112,8 @@ export const AgentsPageView: FC = ({ ]), ); + const scrollContainerRef = useRef(null); + const outletContextValue: AgentsOutletContext = { chatErrorReasons, setChatErrorReason, @@ -119,6 +124,8 @@ export const AgentsPageView: FC = ({ isSidebarCollapsed, onToggleSidebarCollapsed, onExpandSidebar, + onChatReady: () => {}, + scrollContainerRef, }; return (