authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-13 13:35:29+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 13:35:29+00:00
log98a6246d07624432a6e6d0ab4fb87bede90a49a6
treeb89a9528c3d0a46bed9c9bc0947e4cf4ec8f59ef
parent6524a8d43795dc543f7cfb572388cdea3662ad48
signature Signed by PGP key B5690EEEBB952194

fix(web): links in HTML renders open like chat links (#18897)


7 files changed, 72 insertions(+), 14 deletions(-)

apps/web/src/components/chat/HtmlRenderFrame.tsx+3-1
......@@ -1,4 +1,4 @@
1import type { EnvironmentId } from "@t3tools/contracts";
1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
22import {
33 HTML_RENDER_COLUMN_WIDTH,
44 htmlRenderFileName,
......@@ -28,6 +28,7 @@ const MIN_URL_LIFE_MS = 5 * 60_000;
2828export function HtmlRenderFrame(props: {
2929 readonly environmentId: EnvironmentId;
3030 readonly htmlRender: HtmlRenderReference;
31 readonly threadRef: ScopedThreadRef | undefined;
3132 readonly onOpen: (attachment: ChatFileAttachment) => void;
3233}) {
3334 const { attachmentId, title } = props.htmlRender;
......@@ -97,6 +98,7 @@ export function HtmlRenderFrame(props: {
9798 <HtmlRenderDocument
9899 src={src}
99100 title={title}
101 threadRef={props.threadRef}
100102 className="block size-full"
101103 onContentHeight={setContentHeight}
102104 />
apps/web/src/components/chat/MessagesTimeline.tsx+1
......@@ -3052,6 +3052,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm
30523052 key={row.htmlRender.attachmentId}
30533053 environmentId={ctx.activeThreadEnvironmentId}
30543054 htmlRender={row.htmlRender}
3055 threadRef={ctx.threadRef ?? undefined}
30553056 onOpen={ctx.onFileOpen}
30563057 />
30573058 </div>
apps/web/src/components/files/AttachmentFilePreview.tsx+4-1
......@@ -1,5 +1,5 @@
11import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
2import type { EnvironmentId } from "@t3tools/contracts";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
33import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
44import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview";
55import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview";
......@@ -61,6 +61,8 @@ export function AttachmentFilePreview(props: {
6161 asset?: { environmentId: EnvironmentId; attachmentId: string };
6262 /** An agent's HTML render, shown in the app theme. */
6363 htmlRender?: boolean;
64 /** The thread an HTML render's links open beside. */
65 threadRef?: ScopedThreadRef;
6466 /** First crumb: where the file comes from. */
6567 origin?: string;
6668 onRemove?: () => void;
......@@ -250,6 +252,7 @@ export function AttachmentFilePreview(props: {
250252 title={props.name}
251253 pdf={kind === "pdf"}
252254 htmlRender={props.htmlRender === true}
255 threadRef={props.threadRef}
253256 />
254257 ) : kind === "audio" ? (
255258 <AudioPreview src={url} name={props.name} onError={() => setError("Unable to load audio.")} />
apps/web/src/components/files/BrowserDocumentFrame.tsx+37-2
......@@ -1,3 +1,4 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
12import {
23 htmlRenderThemeFragment,
34 htmlRenderThemeMessage,
......@@ -5,9 +6,12 @@ import {
56 readHtmlRenderContentHeight,
67 readHtmlRenderLinkRequest,
78} from "@t3tools/shared/htmlRender";
8import { useEffect, useLayoutEffect, useRef, useState } from "react";
9import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
910
11import { useOpenLink } from "~/browser/useOpenLink";
1012import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
13import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
14import { readLocalApi } from "~/localApi";
1115import { cn } from "~/lib/utils";
1216
1317/**
......@@ -32,6 +36,8 @@ export function BrowserDocumentFrame(props: {
3236 readonly title: string;
3337 readonly pdf: boolean;
3438 readonly htmlRender?: boolean;
39 /** The thread an HTML render's links open beside. */
40 readonly threadRef?: ScopedThreadRef | undefined;
3541}) {
3642 const className = "min-h-0 flex-1 border-0 bg-white";
3743 return props.pdf ? (
......@@ -47,6 +53,7 @@ export function BrowserDocumentFrame(props: {
4753 key={props.src}
4854 src={props.src}
4955 title={props.title}
56 threadRef={props.threadRef}
5057 className="min-h-0 flex-1"
5158 />
5259 ) : (
......@@ -70,6 +77,8 @@ export function HtmlRenderDocument(props: {
7077 readonly src: string;
7178 readonly title: string;
7279 readonly className?: string;
80 /** The thread the page's links open beside, as a chat link would. */
81 readonly threadRef?: ScopedThreadRef | undefined;
7382 /** Receives the page's content height whenever it changes, so an inline frame can fit it. */
7483 readonly onContentHeight?: (height: number) => void;
7584}) {
......@@ -81,6 +90,32 @@ export function HtmlRenderDocument(props: {
8190 frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*");
8291 };
8392 useEffect(postTheme, [theme]);
93 const openChangeRequestLink = useOpenChangeRequestLink(props.threadRef);
94 const openLink = useOpenLink(props.threadRef);
95 // Same rules as a chat link: a pull request opens in the right panel, any
96 // other link follows "Open links in", and Cmd/Ctrl-click goes to the system
97 // browser. Without a thread there is no panel, so it goes to the browser.
98 const open = useEffectEvent((url: string, modifiers: { metaKey: boolean; ctrlKey: boolean }) => {
99 // The modifier's destination needs no saved setting, so it never waits on one.
100 if (modifiers.metaKey || modifiers.ctrlKey) {
101 void readLocalApi()
102 ?.shell.openExternal(url)
103 .catch((cause: unknown) => console.error(cause));
104 return;
105 }
106 if (
107 props.threadRef !== undefined &&
108 openChangeRequestLink(
109 { ...modifiers, preventDefault: () => undefined, stopPropagation: () => undefined },
110 url,
111 undefined,
112 props.threadRef.environmentId,
113 )
114 ) {
115 return;
116 }
117 void openLink(url, { event: modifiers }).catch((cause: unknown) => console.error(cause));
118 });
84119 // The page cannot open windows itself (an inline page runs unopened, and
85120 // desktop sends any window to the browser). It asks the client, which opens
86121 // the link only while this frame has focus and the reader has just used the
......@@ -99,7 +134,7 @@ export function HtmlRenderDocument(props: {
99134 ) {
100135 return;
101136 }
102 window.open(request.url, "_blank", "noopener,noreferrer");
137 open(request.url, request);
103138 frame.contentWindow?.postMessage(htmlRenderResult(request.id), "*");
104139 };
105140 window.addEventListener("message", openLink);
apps/web/src/components/files/FilePreviewPanel.tsx+1
......@@ -1352,6 +1352,7 @@ export default function FilePreviewPanel({
13521352 sizeBytes={attachment.sizeBytes}
13531353 asset={{ environmentId, attachmentId: attachment.id }}
13541354 htmlRender={attachment.htmlRender === true}
1355 threadRef={threadRef}
13551356 />
13561357 ) : relativePath && isVideo && absolutePath ? (
13571358 <WorkspaceVideoPreview
packages/shared/src/htmlRender.test.ts+7-2
......@@ -76,16 +76,21 @@ describe("injectHtmlRenderBootstrap", () => {
7676
7777describe("readHtmlRenderLinkRequest", () => {
7878 it("accepts only http(s) URLs in an MCP Apps ui/open-link request", () => {
79 const link = (url: unknown) => ({
79 const link = (url: unknown, modifiers = {}) => ({
8080 jsonrpc: "2.0",
8181 id: 1,
8282 method: "ui/open-link",
83 params: { url },
83 params: { url, ...modifiers },
8484 });
8585 expect(readHtmlRenderLinkRequest(link("https://example.com/a"))).toEqual({
8686 id: 1,
8787 url: "https://example.com/a",
88 metaKey: false,
89 ctrlKey: false,
8890 });
91 expect(
92 readHtmlRenderLinkRequest(link("https://example.com/a", { metaKey: true, ctrlKey: "yes" })),
93 ).toEqual({ id: 1, url: "https://example.com/a", metaKey: true, ctrlKey: false });
8994 expect(readHtmlRenderLinkRequest(link("javascript:alert(1)"))).toBeUndefined();
9095 expect(readHtmlRenderLinkRequest(link("file:///etc/passwd"))).toBeUndefined();
9196 expect(
packages/shared/src/htmlRender.ts+19-8
......@@ -287,17 +287,28 @@ export function readHtmlRenderContentHeight(data: unknown): number | undefined {
287287 return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined;
288288}
289289
290/** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */
291export function readHtmlRenderLinkRequest(
292 data: unknown,
293): { readonly id: string | number; readonly url: string } | undefined {
290/**
291 * A render's `ui/open-link` request, if `data` is one with an http(s) URL. The
292 * bootstrap adds the click's Cmd/Ctrl state, so a modifier click can leave the
293 * app the way it does from a chat link.
294 */
295export function readHtmlRenderLinkRequest(data: unknown):
296 | {
297 readonly id: string | number;
298 readonly url: string;
299 readonly metaKey: boolean;
300 readonly ctrlKey: boolean;
301 }
302 | undefined {
294303 if (typeof data !== "object" || data === null) return undefined;
295304 const { jsonrpc, id, method, params } = data as Record<string, unknown>;
296305 if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined;
297306 if (typeof id !== "string" && typeof id !== "number") return undefined;
298 const url =
299 typeof params === "object" && params !== null ? (params as { url?: unknown }).url : undefined;
300 return typeof url === "string" && /^https?:\/\//i.test(url) ? { id, url } : undefined;
307 if (typeof params !== "object" || params === null) return undefined;
308 const { url, metaKey, ctrlKey } = params as Record<string, unknown>;
309 return typeof url === "string" && /^https?:\/\//i.test(url)
310 ? { id, url, metaKey: metaKey === true, ctrlKey: ctrlKey === true }
311 : undefined;
301312}
302313
303314/** The empty result a client sends back for a render's request. */
......@@ -345,7 +356,7 @@ function rootRule(theme: HtmlRenderTheme): string {
345356// opens it as a new window, which the client sends to the browser. A framed
346357// page also reports its content height, measured as the server measures it,
347358// so its client can fit the frame to the page.
348const 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);}})();`;
359const 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,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}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);}})();`;
349360
350361function bootstrapMarkup(markup: string): string {
351362 const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");