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 (