| 1 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | HTML_RENDER_COLUMN_WIDTH, |
| 4 | htmlRenderFileName, |
| 5 | htmlRenderFrameHeight, |
| 6 | readHtmlRenderContentSize, |
| 7 | type HtmlRenderReference, |
| 8 | } from "@t3tools/shared/htmlRender"; |
| 9 | import { Maximize2Icon } from "lucide-react"; |
| 10 | import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 11 | |
| 12 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; |
| 13 | |
| 14 | import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; |
| 15 | import { Button } from "../ui/button"; |
| 16 | import { toastManager } from "../ui/toast"; |
| 17 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 18 | import { HtmlRenderDialog } from "./HtmlRenderDialog"; |
| 19 | import { observeResize } from "~/lib/observeResize"; |
| 20 | |
| 21 | // A frame may load its URL a little after mounting. |
| 22 | const 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 | */ |
| 30 | export 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 | } |