1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
2import {
3 HTML_RENDER_COLUMN_WIDTH,
4 htmlRenderFileName,
5 htmlRenderFrameHeight,
6 readHtmlRenderContentSize,
7 type HtmlRenderReference,
8} from "@t3tools/shared/htmlRender";
9import { Maximize2Icon } from "lucide-react";
10import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
11
12import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
13
14import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
15import { Button } from "../ui/button";
16import { toastManager } from "../ui/toast";
17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
18import { HtmlRenderDialog } from "./HtmlRenderDialog";
19import { observeResize } from "~/lib/observeResize";
20
21// A frame may load its URL a little after mounting.
22const MIN_URL_LIFE_MS = 5 * 60_000;
23
24/**
25 * An agent's HTML render inline in the thread: the page itself on the thread's
26 * own background, at the server's measured height for this width until the
27 * page reports its own. Loading and failure hold the same box so nothing below
28 * it moves.
29 */
30export function HtmlRenderFrame(props: {
31 readonly environmentId: EnvironmentId;
32 readonly htmlRender: HtmlRenderReference;
33 readonly threadRef: ScopedThreadRef | undefined;
34 readonly onFullscreenChange?: (fullscreen: boolean) => void;
35}) {
36 const { attachmentId, title } = props.htmlRender;
37 // The frame takes the page's measured height at its own width, read before
38 // first paint so the reserved box is already the right size.
39 const boxRef = useRef<HTMLDivElement>(null);
40 const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH);
41 useLayoutEffect(() => {
42 const box = boxRef.current;
43 if (!box) return;
44 setWidth(box.clientWidth);
45 return observeResize(box, ([entry]) => {
46 if (entry) setWidth(entry.contentRect.width);
47 });
48 }, []);
49 // Client fonts can wrap a page taller than the server measured it; a frame
50 // left short would scroll inside the thread and take the reader's scroll.
51 const [contentSize, setContentSize] = useState<ReturnType<typeof readHtmlRenderContentSize>>();
52 const height = htmlRenderFrameHeight(props.htmlRender, width, contentSize?.height);
53 const fileName = htmlRenderFileName(title);
54 const resource = useMemo(
55 () => ({
56 _tag: "attachment" as const,
57 attachmentId,
58 fileName,
59 mimeType: "text/html",
60 disposition: "inline" as const,
61 }),
62 [attachmentId, fileName],
63 );
64 // A cached URL with life left is reused, so the browser's cache serves the
65 // page again; one near expiry is minted afresh, since a frame cannot report a
66 // failed load. The page keeps its first URL for its lifetime.
67 const [src, setSrc] = useState<string | null>(null);
68 const [expandedSrc, setExpandedSrc] = useState<string | null>(null);
69 const { onFullscreenChange } = props;
70 useEffect(() => {
71 if (expandedSrc === null) return;
72 onFullscreenChange?.(true);
73 return () => onFullscreenChange?.(false);
74 }, [expandedSrc, onFullscreenChange]);
75 const [failed, setFailed] = useState(false);
76 const assetUrl = useAssetUrlState(src === null ? props.environmentId : null, resource);
77 const cachedUrl = assetUrl._tag === "Success" ? assetUrl.url : null;
78 const cachedExpiresAt = assetUrl._tag === "Success" ? assetUrl.expiresAt : 0;
79 const cacheFailed = assetUrl._tag === "Failure";
80 const refresh = useAssetUrlRefresh(props.environmentId, resource);
81 // At most one mint per mount: expiry is server time and the check uses the
82 // client clock, so a skewed clock must not mint again on every update.
83 const minting = useRef(false);
84 useEffect(() => {
85 if (src !== null || minting.current) return;
86 if (cacheFailed) {
87 // oxlint-disable-next-line react/set-state-in-effect -- Mirrors the cached URL's failure.
88 setFailed(true);
89 return;
90 }
91 if (cachedUrl === null) return;
92 if (cachedExpiresAt - Date.now() > MIN_URL_LIFE_MS) {
93 setSrc(cachedUrl);
94 return;
95 }
96 minting.current = true;
97 void refresh().then(
98 (url) => (url === null ? setFailed(true) : setSrc(url)),
99 () => setFailed(true),
100 );
101 }, [cacheFailed, cachedExpiresAt, cachedUrl, refresh, src]);
102
103 return (
104 <div
105 ref={boxRef}
106 className="group/html-render relative"
107 style={{ height, width: contentSize?.width }}
108 >
109 {src !== null ? (
110 <>
111 <HtmlRenderDocument
112 src={src}
113 title={title}
114 threadRef={props.threadRef}
115 className="block size-full"
116 onContentSize={setContentSize}
117 />
118 <div className="absolute end-2 top-2 flex gap-1 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/html-render:opacity-100 pointer-coarse:opacity-100">
119 <Tooltip>
120 <TooltipTrigger
121 render={
122 <Button
123 aria-label="Open full screen"
124 size="icon-xs"
125 variant="glass"
126 onClick={async () => {
127 const url = await refresh().catch(() => null);
128 if (url !== null) setExpandedSrc(url);
129 else
130 toastManager.add({
131 type: "error",
132 title: "Page unavailable",
133 description: "Reconnect to the environment and try again.",
134 });
135 }}
136 />
137 }
138 >
139 <Maximize2Icon className="size-3.5" />
140 </TooltipTrigger>
141 <TooltipPopup side="left">Open full screen</TooltipPopup>
142 </Tooltip>
143 </div>
144 {expandedSrc !== null ? (
145 <HtmlRenderDialog
146 src={expandedSrc}
147 title={title}
148 threadRef={props.threadRef}
149 onClose={() => setExpandedSrc(null)}
150 />
151 ) : null}
152 </>
153 ) : failed ? (
154 <p className="flex size-full items-center justify-center text-muted-foreground text-xs">
155 Unable to load {title}
156 </p>
157 ) : null}
158 </div>
159 );
160}