authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 18:59:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 18:59:04-07:00
log64275ae39653379e63f49f87be021c190fd20e81
tree97870797e4a7d7be463ee11e3ac1464fc5af22f1
parentdb4d0a8e6e49d92a9efcd8d6f82bbed10e2bd748
signature Signed by PGP key B5690EEEBB952194

fix(web): inline HTML renders no longer trap the thread's scroll (#16283)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 111 insertions(+), 24 deletions(-)

apps/server/src/mcp/toolkits/html/tools.ts+2-2
......@@ -68,7 +68,7 @@ export const HtmlPreviewTool = Tool.make("html_preview", {
6868// touches no workspace, so plan mode and read-only sandboxes can use it.
6969// Open-world, since the page may load remote resources, as in a preview.
7070const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, {
71 description: `Show a finished HTML page (chart, table, diagram, collage, mockup) inline in this thread, above your final text reply; call it before writing that reply. The reader already sees the page, so the reply should not announce it, say where it is, or restate it: add only what the page doesn't say. Preview with html_preview first. T3 fits the frame to the page's height at each reader's width, up to height; anything taller scrolls inside the frame. ${PAGE_RULES} ${HTML_RENDER_LAYOUT_GUIDE} ${HTML_RENDER_THEME_GUIDE}`,
71 description: `Show a finished HTML page (chart, table, diagram, collage, mockup) inline in this thread, above your final text reply; call it before writing that reply. The reader already sees the page, so the reply should not announce it, say where it is, or restate it: add only what the page doesn't say. Preview with html_preview first. T3 fits the frame to the page's height at each reader's width. A height below the page's contentHeight caps the frame there, and the rest scrolls inside it. ${PAGE_RULES} ${HTML_RENDER_LAYOUT_GUIDE} ${HTML_RENDER_THEME_GUIDE}`,
7272 parameters: Schema.Struct({
7373 html: Html,
7474 title: Schema.String.check(
......@@ -76,7 +76,7 @@ const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, {
7676 Schema.isMaxLength(HTML_RENDER_MAX_TITLE_LENGTH),
7777 ).annotate({ description: "Short name for the page." }),
7878 height: Schema.Int.annotate({
79 description: `The most the frame may take, in CSS pixels, ${HTML_RENDER_MIN_HEIGHT}-${HTML_RENDER_MAX_HEIGHT}. Use html_preview's contentHeight, or less to make long content scroll inside the frame.`,
79 description: `The frame height in CSS pixels, ${HTML_RENDER_MIN_HEIGHT}-${HTML_RENDER_MAX_HEIGHT}. Use html_preview's contentHeight, or less to make long content scroll inside the frame.`,
8080 }),
8181 }),
8282 success: Schema.Struct({
apps/web/src/components/chat/HtmlRenderFrame.tsx+13-4
......@@ -20,8 +20,9 @@ const MIN_URL_LIFE_MS = 5 * 60_000;
2020
2121/**
2222 * An agent's HTML render inline in the thread: the page itself on the thread's
23 * own background, at its measured height for this width (capped at the agent's
24 * height). Loading and failure hold the same box so nothing below it moves.
23 * own background, at the server's measured height for this width until the
24 * page reports its own. Loading and failure hold the same box so nothing below
25 * it moves.
2526 */
2627export function HtmlRenderFrame(props: {
2728 readonly environmentId: EnvironmentId;
......@@ -43,7 +44,10 @@ export function HtmlRenderFrame(props: {
4344 observer.observe(box);
4445 return () => observer.disconnect();
4546 }, []);
46 const height = htmlRenderFrameHeight(props.htmlRender, width);
47 // Client fonts can wrap a page taller than the server measured it; a frame
48 // left short would scroll inside the thread and take the reader's scroll.
49 const [contentHeight, setContentHeight] = useState<number>();
50 const height = htmlRenderFrameHeight(props.htmlRender, width, contentHeight);
4751 const fileName = htmlRenderFileName(title);
4852 const resource = useMemo(
4953 () => ({
......@@ -91,7 +95,12 @@ export function HtmlRenderFrame(props: {
9195 <div ref={boxRef} className="group/html-render relative" style={{ height }}>
9296 {src !== null ? (
9397 <>
94 <HtmlRenderDocument src={src} title={title} className="block size-full" />
98 <HtmlRenderDocument
99 src={src}
100 title={title}
101 className="block size-full"
102 onContentHeight={setContentHeight}
103 />
95104 <div className="absolute end-2 top-2 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/html-render:opacity-100 pointer-coarse:opacity-100">
96105 <Tooltip>
97106 <TooltipTrigger
apps/web/src/components/files/BrowserDocumentFrame.tsx+18-1
......@@ -2,9 +2,10 @@ import {
22 htmlRenderThemeFragment,
33 htmlRenderThemeMessage,
44 htmlRenderResult,
5 readHtmlRenderContentHeight,
56 readHtmlRenderLinkRequest,
67} from "@t3tools/shared/htmlRender";
7import { useEffect, useRef, useState } from "react";
8import { useEffect, useLayoutEffect, useRef, useState } from "react";
89
910import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
1011import { cn } from "~/lib/utils";
......@@ -69,6 +70,8 @@ export function HtmlRenderDocument(props: {
6970 readonly src: string;
7071 readonly title: string;
7172 readonly className?: string;
73 /** Receives the page's content height whenever it changes, so an inline frame can fit it. */
74 readonly onContentHeight?: (height: number) => void;
7275}) {
7376 const theme = useHtmlRenderTheme();
7477 const frameRef = useRef<HTMLIFrameElement>(null);
......@@ -102,6 +105,20 @@ export function HtmlRenderDocument(props: {
102105 window.addEventListener("message", openLink);
103106 return () => window.removeEventListener("message", openLink);
104107 }, []);
108 const { onContentHeight } = props;
109 // A page posts its height once per change, so listen from the commit that
110 // inserts the frame; a passive effect could run after a fast page's first post.
111 useLayoutEffect(() => {
112 if (onContentHeight === undefined) return;
113 const resize = (event: MessageEvent) => {
114 const height = readHtmlRenderContentHeight(event.data);
115 if (height !== undefined && event.source === frameRef.current?.contentWindow) {
116 onContentHeight(height);
117 }
118 };
119 window.addEventListener("message", resize);
120 return () => window.removeEventListener("message", resize);
121 }, [onContentHeight]);
105122 return (
106123 <iframe
107124 ref={frameRef}
packages/shared/src/htmlRender.test.ts+32-2
......@@ -1,12 +1,14 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 HTML_RENDER_MAX_HEIGHT,
45 htmlRenderFrameHeight,
56 htmlRenderReferencesEqual,
67 htmlRenderTheme,
78 htmlRenderThemeFragment,
89 injectHtmlRenderBootstrap,
910 htmlRenderThemeMessage,
11 readHtmlRenderContentHeight,
1012 readHtmlRenderLinkRequest,
1113 readHtmlRenderReference,
1214} from "./htmlRender.ts";
......@@ -99,6 +101,22 @@ describe("readHtmlRenderLinkRequest", () => {
99101 });
100102});
101103
104describe("readHtmlRenderContentHeight", () => {
105 it("reads only the height of an MCP Apps size-changed notification", () => {
106 const notification = (params: unknown) => ({
107 jsonrpc: "2.0",
108 method: "ui/notifications/size-changed",
109 params,
110 });
111 expect(readHtmlRenderContentHeight(notification({ height: 412 }))).toBe(412);
112 expect(readHtmlRenderContentHeight(notification({ height: "412" }))).toBe(undefined);
113 expect(readHtmlRenderContentHeight(notification({ height: 0 }))).toBe(undefined);
114 expect(readHtmlRenderContentHeight({ ...notification({ height: 412 }), method: "x" })).toBe(
115 undefined,
116 );
117 });
118});
119
102120describe("htmlRenderThemeMessage", () => {
103121 it("is an MCP Apps host-context-changed notification carrying the theme variables", () => {
104122 const theme = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");
......@@ -193,9 +211,21 @@ describe("htmlRenderFrameHeight", () => {
193211 expect(htmlRenderFrameHeight(responsive, 640)).toBe(450);
194212 });
195213
196 it("never exceeds the agent's height and falls back to it without measurements", () => {
197 expect(htmlRenderFrameHeight(measured, 1400)).toBe(1500);
214 it("fits a page the client lays out taller than the server measured", () => {
215 // The agent passed contentHeight at the column width, so the page should never scroll.
216 const fitted = { ...measured, height: 1403 };
217 expect(htmlRenderFrameHeight(fitted, 728, 1415)).toBe(1415);
218 expect(htmlRenderFrameHeight(fitted, 1400)).toBe(1660);
219 expect(htmlRenderFrameHeight(fitted, 728, 5000)).toBe(HTML_RENDER_MAX_HEIGHT);
220 });
221
222 it("keeps the agent's height when it asked for a scrolling frame or the page is unmeasured", () => {
223 const scrolling = { ...measured, height: 600 };
224 expect(htmlRenderFrameHeight(scrolling, 728, 1415)).toBe(600);
225 expect(htmlRenderFrameHeight(scrolling, 1400)).toBe(600);
198226 expect(htmlRenderFrameHeight(reference, 728)).toBe(reference.height);
227 expect(htmlRenderFrameHeight(reference, 728, 900)).toBe(reference.height);
228 expect(htmlRenderFrameHeight(reference, 728, 300)).toBe(300);
199229 });
200230
201231 it("drops a malformed table and compares tables by value", () => {
packages/shared/src/htmlRender.ts+46-15
......@@ -98,22 +98,37 @@ export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: Html
9898 );
9999}
100100
101/**
102 * The frame height for a page at a frame width: the taller of the heights
103 * measured at the nearest widths on each side, capped at the agent's height.
104 * A breakpoint between two measured widths can make the page as tall as
105 * either, so the frame takes the taller. A page whose height grows with its
106 * width inside one layout can still run slightly past the frame between
107 * measured widths.
108 */
109export function htmlRenderFrameHeight(reference: HtmlRenderReference, width: number) {
110 const heights = reference.heights;
111 if (heights === undefined || heights.length === 0) return reference.height;
101// The taller of the heights measured at the nearest widths on each side. A
102// breakpoint between two measured widths can make the page as tall as either.
103function measuredHeight(heights: NonNullable<HtmlRenderReference["heights"]>, width: number) {
112104 const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width);
113105 const high = above === -1 ? heights.length - 1 : above;
114106 const low = heights[high]![0] === width ? high : Math.max(0, high - 1);
115 const measured = Math.max(heights[low]![1], heights[high]![1]);
116 return clampHtmlRenderHeight(Math.min(reference.height, measured));
107 return Math.max(heights[low]![1], heights[high]![1]);
108}
109
110/**
111 * The frame height for a page at a frame width. It is the page's own reported
112 * `contentHeight` when the client has one, else the server's measurement for
113 * that width. A page even a few pixels taller than its frame scrolls inside it
114 * and takes the reader's scroll, so the frame fits the page. The agent's height
115 * caps it only when it is below the page's height at the column width (the
116 * agent asked for a scrolling frame) or when the page was never measured.
117 */
118export function htmlRenderFrameHeight(
119 reference: HtmlRenderReference,
120 width: number,
121 contentHeight?: number,
122) {
123 const heights = reference.heights;
124 if (heights === undefined || heights.length === 0) {
125 return clampHtmlRenderHeight(Math.min(reference.height, contentHeight ?? reference.height));
126 }
127 const cap =
128 measuredHeight(heights, HTML_RENDER_COLUMN_WIDTH) > reference.height
129 ? reference.height
130 : HTML_RENDER_MAX_HEIGHT;
131 return clampHtmlRenderHeight(Math.min(cap, contentHeight ?? measuredHeight(heights, width)));
117132}
118133
119134/** A readable download name: the title without characters file systems reject. */
......@@ -248,6 +263,7 @@ export const HTML_RENDER_LAYOUT_GUIDE = [
248263 `The frame is borderless on the thread's background, as wide as the reply column (${HTML_RENDER_COLUMN_WIDTH}px on desktop by default, wider if the reader widens chat, about 360px on phones), and its left edge lines up with your reply text.`,
249264 "Use a fluid width with no horizontal padding on the outermost element, and no outer card, border, or banner title: the page is part of your reply.",
250265 "Give charts fixed pixel heights rather than heights that scale with width.",
266 "Let content set the page's height. Avoid viewport-based heights such as 100vh or height:100% on html or body; the frame grows to fit the page, so they can make it grow again and again.",
251267].join(" ");
252268
253269// The bridge between a render and its client speaks the MCP Apps protocol
......@@ -255,6 +271,19 @@ export const HTML_RENDER_LAYOUT_GUIDE = [
255271// MCP apps: https://github.com/modelcontextprotocol/ext-apps
256272const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed";
257273const OPEN_LINK_METHOD = "ui/open-link";
274const SIZE_CHANGED_METHOD = "ui/notifications/size-changed";
275
276/** The content height in a framed render's `ui/notifications/size-changed` notification. */
277export function readHtmlRenderContentHeight(data: unknown): number | undefined {
278 if (typeof data !== "object" || data === null) return undefined;
279 const { jsonrpc, method, params } = data as Record<string, unknown>;
280 if (jsonrpc !== "2.0" || method !== SIZE_CHANGED_METHOD) return undefined;
281 const height =
282 typeof params === "object" && params !== null
283 ? (params as { height?: unknown }).height
284 : undefined;
285 return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined;
286}
258287
259288/** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */
260289export function readHtmlRenderLinkRequest(
......@@ -309,8 +338,10 @@ function rootRule(theme: HtmlRenderTheme): string {
309338// then drops the fragment so a page's own hash routing never sees it. A link
310339// the reader clicks to another page never replaces the page inside the thread:
311340// a framed page asks its client to open it, and a top-level page (mobile)
312// opens it as a new window, which the client sends to the browser.
313const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);})();`;
341// opens it as a new window, which the client sends to the browser. A framed
342// page also reports its content height, measured as the server measures it,
343// so its client can fit the frame to the page.
344const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);}})();`;
314345
315346function bootstrapMarkup(markup: string): string {
316347 const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");