Skip to content
Merged
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
31 changes: 31 additions & 0 deletions apps/web/src/components/chat/HtmlRenderDocument.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,35 @@ describe("HtmlRenderDocument", () => {
"*",
);
});
it("puts the page on the surface hosting the frame, not the palette canvas", async () => {
let surface = "#181818";
vi.stubGlobal("getComputedStyle", () => ({ getPropertyValue: () => ` ${surface}` }));
const onSurface = (background: string) => ({
...activeTheme,
variables: { ...activeTheme.variables, "--background": background },
});
await mount();
expect(renderer!.root.findByType("iframe").props.src).toBe(
`https://environment.example/Chart.html${htmlRenderThemeFragment(onSurface("#181818"))}`,
);
expect(frame.contentWindow.postMessage).toHaveBeenLastCalledWith(
htmlRenderThemeMessage(onSurface("#181818")),
"*",
);

// A theme change repaints the host first, then reaches the frame.
surface = "#fdf7fd";
activeTheme = htmlRenderTheme(Pathway_CODE_LIGHT_THEME_COLORS, "light");
const src = renderer!.root.findByType("iframe").props.src;
await act(async () =>
renderer!.update(
<HtmlRenderDocument src="https://environment.example/Chart.html" title="Chart" />,
),
);
expect(renderer!.root.findByType("iframe").props.src).toBe(src);
expect(frame.contentWindow.postMessage).toHaveBeenLastCalledWith(
htmlRenderThemeMessage(onSurface("#fdf7fd")),
"*",
);
});
});
36 changes: 33 additions & 3 deletions apps/web/src/components/chat/HtmlRenderDocument.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {
type HtmlRenderTheme,
htmlRenderResult,
htmlRenderThemeFragment,
htmlRenderThemeMessage,
readHtmlRenderContentHeight,
readHtmlRenderLinkRequest,
} from "@spiritdevs/shared/htmlRender";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";

import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
import { cn } from "~/lib/utils";
Expand Down Expand Up @@ -35,6 +36,11 @@ export function openHtmlRenderUrl(url: string) {
);
}

function withBackground(theme: HtmlRenderTheme, background: string | undefined): HtmlRenderTheme {
if (background === undefined || background === theme.variables["--background"]) return theme;
return { ...theme, variables: { ...theme.variables, "--background": background } };
}

/**
* A sandboxed agent HTML render in the app theme. The page reads the theme from
* its URL fragment before first paint, then follows changes posted to its
Expand All @@ -49,9 +55,33 @@ export function HtmlRenderDocument(props: {
readonly onContentHeight?: (height: number) => void;
readonly onLoad?: () => void;
}) {
const theme = useHtmlRenderTheme();
const paletteTheme = useHtmlRenderTheme();
const frameRef = useRef<HTMLIFrameElement>(null);
const [src] = useState(() => `${props.src.split("#", 1)[0]}${htmlRenderThemeFragment(theme)}`);
const [baseSrc] = useState(() => props.src.split("#", 1)[0]);
const [src, setSrc] = useState(() => `${baseSrc}${htmlRenderThemeFragment(paletteTheme)}`);
const [hostBackground, setHostBackground] = useState<string>();
const theme = useMemo(
() => withBackground(paletteTheme, hostBackground),
[paletteTheme, hostBackground],
);
// The page should sit on the surface that hosts it, and app CSS repaints
// some surfaces past the palette's canvas (dark threads, for one). Theme
// changes apply to the DOM before they reach React, so this reads the new
// surface. The first read also corrects the URL before the page loads.
const checkedFirstSrc = useRef(false);
useLayoutEffect(() => {
const frame = frameRef.current;
const background =
frame === null || typeof getComputedStyle !== "function"
? ""
: getComputedStyle(frame).getPropertyValue("--background").trim();
setHostBackground(background || undefined);
if (checkedFirstSrc.current) return;
checkedFirstSrc.current = true;
if (background) {
setSrc(`${baseSrc}${htmlRenderThemeFragment(withBackground(paletteTheme, background))}`);
}
}, [paletteTheme, baseSrc]);
const [loaded, setLoaded] = useState(false);
const postTheme = () => {
frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*");
Expand Down