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");