diff --git a/apps/web/src/components/chat/HtmlRenderFrame.tsx b/apps/web/src/components/chat/HtmlRenderFrame.tsx
index 5cc9b01e6086c6496d0847369140844d7f00a51a..38dc48861684094d6dedef3ac992758be8c62c7f 100644
--- a/apps/web/src/components/chat/HtmlRenderFrame.tsx
+++ b/apps/web/src/components/chat/HtmlRenderFrame.tsx
@@ -1,4 +1,4 @@
-import type { EnvironmentId } from "@t3tools/contracts";
+import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import {
HTML_RENDER_COLUMN_WIDTH,
htmlRenderFileName,
@@ -28,6 +28,7 @@ const MIN_URL_LIFE_MS = 5 * 60_000;
export function HtmlRenderFrame(props: {
readonly environmentId: EnvironmentId;
readonly htmlRender: HtmlRenderReference;
+ readonly threadRef: ScopedThreadRef | undefined;
readonly onOpen: (attachment: ChatFileAttachment) => void;
}) {
const { attachmentId, title } = props.htmlRender;
@@ -97,6 +98,7 @@ export function HtmlRenderFrame(props: {
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index d0b259d8f022421c6007aa1b99942544e3929655..2ad98d3783329ce7da203d4fe9a81223d212d653 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -3052,6 +3052,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract
diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx
index 68b186d3a04ee209834f8f1d34def9fe8daa3e35..31d80c5c82102d897fa0a0c1d9cd07ebeedbd562 100644
--- a/apps/web/src/components/files/AttachmentFilePreview.tsx
+++ b/apps/web/src/components/files/AttachmentFilePreview.tsx
@@ -1,5 +1,5 @@
import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
-import type { EnvironmentId } from "@t3tools/contracts";
+import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview";
import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview";
@@ -61,6 +61,8 @@ export function AttachmentFilePreview(props: {
asset?: { environmentId: EnvironmentId; attachmentId: string };
/** An agent's HTML render, shown in the app theme. */
htmlRender?: boolean;
+ /** The thread an HTML render's links open beside. */
+ threadRef?: ScopedThreadRef;
/** First crumb: where the file comes from. */
origin?: string;
onRemove?: () => void;
@@ -250,6 +252,7 @@ export function AttachmentFilePreview(props: {
title={props.name}
pdf={kind === "pdf"}
htmlRender={props.htmlRender === true}
+ threadRef={props.threadRef}
/>
) : kind === "audio" ? (
setError("Unable to load audio.")} />
diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx
index ff41865a1e5bfbbd3b259dcf87c49da0fae66b1e..d5c63d24dfc1f80d72f29c6f814a46b032aff7c2 100644
--- a/apps/web/src/components/files/BrowserDocumentFrame.tsx
+++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx
@@ -1,3 +1,4 @@
+import type { ScopedThreadRef } from "@t3tools/contracts";
import {
htmlRenderThemeFragment,
htmlRenderThemeMessage,
@@ -5,9 +6,12 @@ import {
readHtmlRenderContentHeight,
readHtmlRenderLinkRequest,
} from "@t3tools/shared/htmlRender";
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
+import { useOpenLink } from "~/browser/useOpenLink";
import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
+import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
+import { readLocalApi } from "~/localApi";
import { cn } from "~/lib/utils";
/**
@@ -32,6 +36,8 @@ export function BrowserDocumentFrame(props: {
readonly title: string;
readonly pdf: boolean;
readonly htmlRender?: boolean;
+ /** The thread an HTML render's links open beside. */
+ readonly threadRef?: ScopedThreadRef | undefined;
}) {
const className = "min-h-0 flex-1 border-0 bg-white";
return props.pdf ? (
@@ -47,6 +53,7 @@ export function BrowserDocumentFrame(props: {
key={props.src}
src={props.src}
title={props.title}
+ threadRef={props.threadRef}
className="min-h-0 flex-1"
/>
) : (
@@ -70,6 +77,8 @@ export function HtmlRenderDocument(props: {
readonly src: string;
readonly title: string;
readonly className?: string;
+ /** The thread the page's links open beside, as a chat link would. */
+ readonly threadRef?: ScopedThreadRef | undefined;
/** Receives the page's content height whenever it changes, so an inline frame can fit it. */
readonly onContentHeight?: (height: number) => void;
}) {
@@ -81,6 +90,32 @@ export function HtmlRenderDocument(props: {
frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*");
};
useEffect(postTheme, [theme]);
+ const openChangeRequestLink = useOpenChangeRequestLink(props.threadRef);
+ const openLink = useOpenLink(props.threadRef);
+ // Same rules as a chat link: a pull request opens in the right panel, any
+ // other link follows "Open links in", and Cmd/Ctrl-click goes to the system
+ // browser. Without a thread there is no panel, so it goes to the browser.
+ const open = useEffectEvent((url: string, modifiers: { metaKey: boolean; ctrlKey: boolean }) => {
+ // The modifier's destination needs no saved setting, so it never waits on one.
+ if (modifiers.metaKey || modifiers.ctrlKey) {
+ void readLocalApi()
+ ?.shell.openExternal(url)
+ .catch((cause: unknown) => console.error(cause));
+ return;
+ }
+ if (
+ props.threadRef !== undefined &&
+ openChangeRequestLink(
+ { ...modifiers, preventDefault: () => undefined, stopPropagation: () => undefined },
+ url,
+ undefined,
+ props.threadRef.environmentId,
+ )
+ ) {
+ return;
+ }
+ void openLink(url, { event: modifiers }).catch((cause: unknown) => console.error(cause));
+ });
// The page cannot open windows itself (an inline page runs unopened, and
// desktop sends any window to the browser). It asks the client, which opens
// the link only while this frame has focus and the reader has just used the
@@ -99,7 +134,7 @@ export function HtmlRenderDocument(props: {
) {
return;
}
- window.open(request.url, "_blank", "noopener,noreferrer");
+ open(request.url, request);
frame.contentWindow?.postMessage(htmlRenderResult(request.id), "*");
};
window.addEventListener("message", openLink);
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx
index efd7a7fbc90fb724e0145196490c82bc30f8c6b7..4c0767ad5af4c9a362fcbc986d9a440c5553f9df 100644
--- a/apps/web/src/components/files/FilePreviewPanel.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.tsx
@@ -1352,6 +1352,7 @@ export default function FilePreviewPanel({
sizeBytes={attachment.sizeBytes}
asset={{ environmentId, attachmentId: attachment.id }}
htmlRender={attachment.htmlRender === true}
+ threadRef={threadRef}
/>
) : relativePath && isVideo && absolutePath ? (
{
describe("readHtmlRenderLinkRequest", () => {
it("accepts only http(s) URLs in an MCP Apps ui/open-link request", () => {
- const link = (url: unknown) => ({
+ const link = (url: unknown, modifiers = {}) => ({
jsonrpc: "2.0",
id: 1,
method: "ui/open-link",
- params: { url },
+ params: { url, ...modifiers },
});
expect(readHtmlRenderLinkRequest(link("https://example.com/a"))).toEqual({
id: 1,
url: "https://example.com/a",
+ metaKey: false,
+ ctrlKey: false,
});
+ expect(
+ readHtmlRenderLinkRequest(link("https://example.com/a", { metaKey: true, ctrlKey: "yes" })),
+ ).toEqual({ id: 1, url: "https://example.com/a", metaKey: true, ctrlKey: false });
expect(readHtmlRenderLinkRequest(link("javascript:alert(1)"))).toBeUndefined();
expect(readHtmlRenderLinkRequest(link("file:///etc/passwd"))).toBeUndefined();
expect(
diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts
index 24fb2894d8a55511beddafb2847ac40e31a91768..c46b99e4681054b1a09809ce823e3f321041a9c6 100644
--- a/packages/shared/src/htmlRender.ts
+++ b/packages/shared/src/htmlRender.ts
@@ -287,17 +287,28 @@ export function readHtmlRenderContentHeight(data: unknown): number | undefined {
return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined;
}
-/** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */
-export function readHtmlRenderLinkRequest(
- data: unknown,
-): { readonly id: string | number; readonly url: string } | undefined {
+/**
+ * A render's `ui/open-link` request, if `data` is one with an http(s) URL. The
+ * bootstrap adds the click's Cmd/Ctrl state, so a modifier click can leave the
+ * app the way it does from a chat link.
+ */
+export function readHtmlRenderLinkRequest(data: unknown):
+ | {
+ readonly id: string | number;
+ readonly url: string;
+ readonly metaKey: boolean;
+ readonly ctrlKey: boolean;
+ }
+ | undefined {
if (typeof data !== "object" || data === null) return undefined;
const { jsonrpc, id, method, params } = data as Record;
if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined;
if (typeof id !== "string" && typeof id !== "number") return undefined;
- const url =
- typeof params === "object" && params !== null ? (params as { url?: unknown }).url : undefined;
- return typeof url === "string" && /^https?:\/\//i.test(url) ? { id, url } : undefined;
+ if (typeof params !== "object" || params === null) return undefined;
+ const { url, metaKey, ctrlKey } = params as Record;
+ return typeof url === "string" && /^https?:\/\//i.test(url)
+ ? { id, url, metaKey: metaKey === true, ctrlKey: ctrlKey === true }
+ : undefined;
}
/** The empty result a client sends back for a render's request. */
@@ -345,7 +356,7 @@ function rootRule(theme: HtmlRenderTheme): string {
// opens it as a new window, which the client sends to the browser. A framed
// page also reports its content height, measured as the server measures it,
// so its client can fit the frame to the page.
-const 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);}})();`;
+const 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);}})();`;
function bootstrapMarkup(markup: string): string {
const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark");