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", {...@@ -68,7 +68,7 @@ export const HtmlPreviewTool = Tool.make("html_preview", {
68// touches no workspace, so plan mode and read-only sandboxes can use it.68// touches no workspace, so plan mode and read-only sandboxes can use it.
69// Open-world, since the page may load remote resources, as in a preview.69// Open-world, since the page may load remote resources, as in a preview.
70const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, {70const 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}`,
72 parameters: Schema.Struct({72 parameters: Schema.Struct({
73 html: Html,73 html: Html,
74 title: Schema.String.check(74 title: Schema.String.check(
...@@ -76,7 +76,7 @@ const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, {...@@ -76,7 +76,7 @@ const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, {
76 Schema.isMaxLength(HTML_RENDER_MAX_TITLE_LENGTH),76 Schema.isMaxLength(HTML_RENDER_MAX_TITLE_LENGTH),
77 ).annotate({ description: "Short name for the page." }),77 ).annotate({ description: "Short name for the page." }),
78 height: Schema.Int.annotate({78 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.`,
80 }),80 }),
81 }),81 }),
82 success: Schema.Struct({82 success: Schema.Struct({
apps/web/src/components/chat/HtmlRenderFrame.tsx+13-4
...@@ -20,8 +20,9 @@ const MIN_URL_LIFE_MS = 5 * 60_000;...@@ -20,8 +20,9 @@ const MIN_URL_LIFE_MS = 5 * 60_000;
2020
21/**21/**
22 * An agent's HTML render inline in the thread: the page itself on the thread's22 * 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's23 * own background, at the server's measured height for this width until the
24 * height). Loading and failure hold the same box so nothing below it moves.24 * page reports its own. Loading and failure hold the same box so nothing below
25 * it moves.
25 */26 */
26export function HtmlRenderFrame(props: {27export function HtmlRenderFrame(props: {
27 readonly environmentId: EnvironmentId;28 readonly environmentId: EnvironmentId;
...@@ -43,7 +44,10 @@ export function HtmlRenderFrame(props: {...@@ -43,7 +44,10 @@ export function HtmlRenderFrame(props: {
43 observer.observe(box);44 observer.observe(box);
44 return () => observer.disconnect();45 return () => observer.disconnect();
45 }, []);46 }, []);
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);
47 const fileName = htmlRenderFileName(title);51 const fileName = htmlRenderFileName(title);
48 const resource = useMemo(52 const resource = useMemo(
49 () => ({53 () => ({
...@@ -91,7 +95,12 @@ export function HtmlRenderFrame(props: {...@@ -91,7 +95,12 @@ export function HtmlRenderFrame(props: {
91 <div ref={boxRef} className="group/html-render relative" style={{ height }}>95 <div ref={boxRef} className="group/html-render relative" style={{ height }}>
92 {src !== null ? (96 {src !== null ? (
93 <>97 <>
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 />
95 <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">104 <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">
96 <Tooltip>105 <Tooltip>
97 <TooltipTrigger106 <TooltipTrigger
apps/web/src/components/files/BrowserDocumentFrame.tsx+18-1
...@@ -2,9 +2,10 @@ import {...@@ -2,9 +2,10 @@ import {
2 htmlRenderThemeFragment,2 htmlRenderThemeFragment,
3 htmlRenderThemeMessage,3 htmlRenderThemeMessage,
4 htmlRenderResult,4 htmlRenderResult,
5 readHtmlRenderContentHeight,
5 readHtmlRenderLinkRequest,6 readHtmlRenderLinkRequest,
6} from "@t3tools/shared/htmlRender";7} from "@t3tools/shared/htmlRender";
7import { useEffect, useRef, useState } from "react";8import { useEffect, useLayoutEffect, useRef, useState } from "react";
89
9import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";10import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
10import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
...@@ -69,6 +70,8 @@ export function HtmlRenderDocument(props: {...@@ -69,6 +70,8 @@ export function HtmlRenderDocument(props: {
69 readonly src: string;70 readonly src: string;
70 readonly title: string;71 readonly title: string;
71 readonly className?: string;72 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;
72}) {75}) {
73 const theme = useHtmlRenderTheme();76 const theme = useHtmlRenderTheme();
74 const frameRef = useRef<HTMLIFrameElement>(null);77 const frameRef = useRef<HTMLIFrameElement>(null);
...@@ -102,6 +105,20 @@ export function HtmlRenderDocument(props: {...@@ -102,6 +105,20 @@ export function HtmlRenderDocument(props: {
102 window.addEventListener("message", openLink);105 window.addEventListener("message", openLink);
103 return () => window.removeEventListener("message", openLink);106 return () => window.removeEventListener("message", openLink);
104 }, []);107 }, []);
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]);
105 return (122 return (
106 <iframe123 <iframe
107 ref={frameRef}124 ref={frameRef}
packages/shared/src/htmlRender.test.ts+32-2
...@@ -1,12 +1,14 @@...@@ -1,12 +1,14 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 HTML_RENDER_MAX_HEIGHT,
4 htmlRenderFrameHeight,5 htmlRenderFrameHeight,
5 htmlRenderReferencesEqual,6 htmlRenderReferencesEqual,
6 htmlRenderTheme,7 htmlRenderTheme,
7 htmlRenderThemeFragment,8 htmlRenderThemeFragment,
8 injectHtmlRenderBootstrap,9 injectHtmlRenderBootstrap,
9 htmlRenderThemeMessage,10 htmlRenderThemeMessage,
11 readHtmlRenderContentHeight,
10 readHtmlRenderLinkRequest,12 readHtmlRenderLinkRequest,
11 readHtmlRenderReference,13 readHtmlRenderReference,
12} from "./htmlRender.ts";14} from "./htmlRender.ts";
...@@ -99,6 +101,22 @@ describe("readHtmlRenderLinkRequest", () => {...@@ -99,6 +101,22 @@ describe("readHtmlRenderLinkRequest", () => {
99 });101 });
100});102});
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
102describe("htmlRenderThemeMessage", () => {120describe("htmlRenderThemeMessage", () => {
103 it("is an MCP Apps host-context-changed notification carrying the theme variables", () => {121 it("is an MCP Apps host-context-changed notification carrying the theme variables", () => {
104 const theme = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");122 const theme = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");
...@@ -193,9 +211,21 @@ describe("htmlRenderFrameHeight", () => {...@@ -193,9 +211,21 @@ describe("htmlRenderFrameHeight", () => {
193 expect(htmlRenderFrameHeight(responsive, 640)).toBe(450);211 expect(htmlRenderFrameHeight(responsive, 640)).toBe(450);
194 });212 });
195213
196 it("never exceeds the agent's height and falls back to it without measurements", () => {214 it("fits a page the client lays out taller than the server measured", () => {
197 expect(htmlRenderFrameHeight(measured, 1400)).toBe(1500);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);
198 expect(htmlRenderFrameHeight(reference, 728)).toBe(reference.height);226 expect(htmlRenderFrameHeight(reference, 728)).toBe(reference.height);
227 expect(htmlRenderFrameHeight(reference, 728, 900)).toBe(reference.height);
228 expect(htmlRenderFrameHeight(reference, 728, 300)).toBe(300);
199 });229 });
200230
201 it("drops a malformed table and compares tables by value", () => {231 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...@@ -98,22 +98,37 @@ export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: Html
98 );98 );
99}99}
100100
101/**101// The taller of the heights measured at the nearest widths on each side. A
102 * The frame height for a page at a frame width: the taller of the heights102// breakpoint between two measured widths can make the page as tall as either.
103 * measured at the nearest widths on each side, capped at the agent's height.103function measuredHeight(heights: NonNullable<HtmlRenderReference["heights"]>, width: number) {
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;
112 const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width);104 const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width);
113 const high = above === -1 ? heights.length - 1 : above;105 const high = above === -1 ? heights.length - 1 : above;
114 const low = heights[high]![0] === width ? high : Math.max(0, high - 1);106 const low = heights[high]![0] === width ? high : Math.max(0, high - 1);
115 const measured = Math.max(heights[low]![1], heights[high]![1]);107 return Math.max(heights[low]![1], heights[high]![1]);
116 return clampHtmlRenderHeight(Math.min(reference.height, measured));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)));
117}132}
118133
119/** A readable download name: the title without characters file systems reject. */134/** A readable download name: the title without characters file systems reject. */
...@@ -248,6 +263,7 @@ export const HTML_RENDER_LAYOUT_GUIDE = [...@@ -248,6 +263,7 @@ export const HTML_RENDER_LAYOUT_GUIDE = [
248 `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.`,263 `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.`,
249 "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.",264 "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.",
250 "Give charts fixed pixel heights rather than heights that scale with width.",265 "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.",
251].join(" ");267].join(" ");
252268
253// The bridge between a render and its client speaks the MCP Apps protocol269// The bridge between a render and its client speaks the MCP Apps protocol
...@@ -255,6 +271,19 @@ export const HTML_RENDER_LAYOUT_GUIDE = [...@@ -255,6 +271,19 @@ export const HTML_RENDER_LAYOUT_GUIDE = [
255// MCP apps: https://github.com/modelcontextprotocol/ext-apps271// MCP apps: https://github.com/modelcontextprotocol/ext-apps
256const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed";272const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed";
257const OPEN_LINK_METHOD = "ui/open-link";273const 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
259/** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */288/** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */
260export function readHtmlRenderLinkRequest(289export function readHtmlRenderLinkRequest(
...@@ -309,8 +338,10 @@ function rootRule(theme: HtmlRenderTheme): string {...@@ -309,8 +338,10 @@ function rootRule(theme: HtmlRenderTheme): string {
309// then drops the fragment so a page's own hash routing never sees it. A link338// then drops the fragment so a page's own hash routing never sees it. A link
310// the reader clicks to another page never replaces the page inside the thread:339// the reader clicks to another page never replaces the page inside the thread:
311// a framed page asks its client to open it, and a top-level page (mobile)340// 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.341// opens it as a new window, which the client sends to the browser. A framed
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);})();`;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
315function bootstrapMarkup(markup: string): string {346function bootstrapMarkup(markup: string): string {
316 const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");347 const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");