| author | |
| committer | |
| log | 98a6246d07624432a6e6d0ab4fb87bede90a49a6 |
| tree | b89a9528c3d0a46bed9c9bc0947e4cf4ec8f59ef |
| parent | 6524a8d43795dc543f7cfb572388cdea3662ad48 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 72 insertions(+), 14 deletions(-)
apps/web/src/components/chat/HtmlRenderFrame.tsx+3-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | 2 | import { |
| 3 | 3 | HTML_RENDER_COLUMN_WIDTH, |
| 4 | 4 | htmlRenderFileName, |
| ... | ... | @@ -28,6 +28,7 @@ const MIN_URL_LIFE_MS = 5 * 60_000; |
| 28 | 28 | export function HtmlRenderFrame(props: { |
| 29 | 29 | readonly environmentId: EnvironmentId; |
| 30 | 30 | readonly htmlRender: HtmlRenderReference; |
| 31 | readonly threadRef: ScopedThreadRef | undefined; | |
| 31 | 32 | readonly onOpen: (attachment: ChatFileAttachment) => void; |
| 32 | 33 | }) { |
| 33 | 34 | const { attachmentId, title } = props.htmlRender; |
| ... | ... | @@ -97,6 +98,7 @@ export function HtmlRenderFrame(props: { |
| 97 | 98 | <HtmlRenderDocument |
| 98 | 99 | src={src} |
| 99 | 100 | title={title} |
| 101 | threadRef={props.threadRef} | |
| 100 | 102 | className="block size-full" |
| 101 | 103 | onContentHeight={setContentHeight} |
| 102 | 104 | /> |
apps/web/src/components/chat/MessagesTimeline.tsx+1| ... | ... | @@ -3052,6 +3052,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm |
| 3052 | 3052 | key={row.htmlRender.attachmentId} |
| 3053 | 3053 | environmentId={ctx.activeThreadEnvironmentId} |
| 3054 | 3054 | htmlRender={row.htmlRender} |
| 3055 | threadRef={ctx.threadRef ?? undefined} | |
| 3055 | 3056 | onOpen={ctx.onFileOpen} |
| 3056 | 3057 | /> |
| 3057 | 3058 | </div> |
apps/web/src/components/files/AttachmentFilePreview.tsx+4-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | 3 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 4 | 4 | import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; |
| 5 | 5 | import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview"; |
| ... | ... | @@ -61,6 +61,8 @@ export function AttachmentFilePreview(props: { |
| 61 | 61 | asset?: { environmentId: EnvironmentId; attachmentId: string }; |
| 62 | 62 | /** An agent's HTML render, shown in the app theme. */ |
| 63 | 63 | htmlRender?: boolean; |
| 64 | /** The thread an HTML render's links open beside. */ | |
| 65 | threadRef?: ScopedThreadRef; | |
| 64 | 66 | /** First crumb: where the file comes from. */ |
| 65 | 67 | origin?: string; |
| 66 | 68 | onRemove?: () => void; |
| ... | ... | @@ -250,6 +252,7 @@ export function AttachmentFilePreview(props: { |
| 250 | 252 | title={props.name} |
| 251 | 253 | pdf={kind === "pdf"} |
| 252 | 254 | htmlRender={props.htmlRender === true} |
| 255 | threadRef={props.threadRef} | |
| 253 | 256 | /> |
| 254 | 257 | ) : kind === "audio" ? ( |
| 255 | 258 | <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 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | 2 | import { |
| 2 | 3 | htmlRenderThemeFragment, |
| 3 | 4 | htmlRenderThemeMessage, |
| ... | ... | @@ -5,9 +6,12 @@ import { |
| 5 | 6 | readHtmlRenderContentHeight, |
| 6 | 7 | readHtmlRenderLinkRequest, |
| 7 | 8 | } from "@t3tools/shared/htmlRender"; |
| 8 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; | |
| 9 | import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; | |
| 9 | 10 | |
| 11 | import { useOpenLink } from "~/browser/useOpenLink"; | |
| 10 | 12 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; |
| 13 | import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; | |
| 14 | import { readLocalApi } from "~/localApi"; | |
| 11 | 15 | import { cn } from "~/lib/utils"; |
| 12 | 16 | |
| 13 | 17 | /** |
| ... | ... | @@ -32,6 +36,8 @@ export function BrowserDocumentFrame(props: { |
| 32 | 36 | readonly title: string; |
| 33 | 37 | readonly pdf: boolean; |
| 34 | 38 | readonly htmlRender?: boolean; |
| 39 | /** The thread an HTML render's links open beside. */ | |
| 40 | readonly threadRef?: ScopedThreadRef | undefined; | |
| 35 | 41 | }) { |
| 36 | 42 | const className = "min-h-0 flex-1 border-0 bg-white"; |
| 37 | 43 | return props.pdf ? ( |
| ... | ... | @@ -47,6 +53,7 @@ export function BrowserDocumentFrame(props: { |
| 47 | 53 | key={props.src} |
| 48 | 54 | src={props.src} |
| 49 | 55 | title={props.title} |
| 56 | threadRef={props.threadRef} | |
| 50 | 57 | className="min-h-0 flex-1" |
| 51 | 58 | /> |
| 52 | 59 | ) : ( |
| ... | ... | @@ -70,6 +77,8 @@ export function HtmlRenderDocument(props: { |
| 70 | 77 | readonly src: string; |
| 71 | 78 | readonly title: string; |
| 72 | 79 | readonly className?: string; |
| 80 | /** The thread the page's links open beside, as a chat link would. */ | |
| 81 | readonly threadRef?: ScopedThreadRef | undefined; | |
| 73 | 82 | /** Receives the page's content height whenever it changes, so an inline frame can fit it. */ |
| 74 | 83 | readonly onContentHeight?: (height: number) => void; |
| 75 | 84 | }) { |
| ... | ... | @@ -81,6 +90,32 @@ export function HtmlRenderDocument(props: { |
| 81 | 90 | frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); |
| 82 | 91 | }; |
| 83 | 92 | 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 | }); | |
| 84 | 119 | // The page cannot open windows itself (an inline page runs unopened, and |
| 85 | 120 | // desktop sends any window to the browser). It asks the client, which opens |
| 86 | 121 | // the link only while this frame has focus and the reader has just used the |
| ... | ... | @@ -99,7 +134,7 @@ export function HtmlRenderDocument(props: { |
| 99 | 134 | ) { |
| 100 | 135 | return; |
| 101 | 136 | } |
| 102 | window.open(request.url, "_blank", "noopener,noreferrer"); | |
| 137 | open(request.url, request); | |
| 103 | 138 | frame.contentWindow?.postMessage(htmlRenderResult(request.id), "*"); |
| 104 | 139 | }; |
| 105 | 140 | window.addEventListener("message", openLink); |
apps/web/src/components/files/FilePreviewPanel.tsx+1| ... | ... | @@ -1352,6 +1352,7 @@ export default function FilePreviewPanel({ |
| 1352 | 1352 | sizeBytes={attachment.sizeBytes} |
| 1353 | 1353 | asset={{ environmentId, attachmentId: attachment.id }} |
| 1354 | 1354 | htmlRender={attachment.htmlRender === true} |
| 1355 | threadRef={threadRef} | |
| 1355 | 1356 | /> |
| 1356 | 1357 | ) : relativePath && isVideo && absolutePath ? ( |
| 1357 | 1358 | <WorkspaceVideoPreview |
packages/shared/src/htmlRender.test.ts+7-2| ... | ... | @@ -76,16 +76,21 @@ describe("injectHtmlRenderBootstrap", () => { |
| 76 | 76 | |
| 77 | 77 | describe("readHtmlRenderLinkRequest", () => { |
| 78 | 78 | 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 = {}) => ({ | |
| 80 | 80 | jsonrpc: "2.0", |
| 81 | 81 | id: 1, |
| 82 | 82 | method: "ui/open-link", |
| 83 | params: { url }, | |
| 83 | params: { url, ...modifiers }, | |
| 84 | 84 | }); |
| 85 | 85 | expect(readHtmlRenderLinkRequest(link("https://example.com/a"))).toEqual({ |
| 86 | 86 | id: 1, |
| 87 | 87 | url: "https://example.com/a", |
| 88 | metaKey: false, | |
| 89 | ctrlKey: false, | |
| 88 | 90 | }); |
| 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 }); | |
| 89 | 94 | expect(readHtmlRenderLinkRequest(link("javascript:alert(1)"))).toBeUndefined(); |
| 90 | 95 | expect(readHtmlRenderLinkRequest(link("file:///etc/passwd"))).toBeUndefined(); |
| 91 | 96 | expect( |
packages/shared/src/htmlRender.ts+19-8| ... | ... | @@ -287,17 +287,28 @@ export function readHtmlRenderContentHeight(data: unknown): number | undefined { |
| 287 | 287 | return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined; |
| 288 | 288 | } |
| 289 | 289 | |
| 290 | /** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */ | |
| 291 | export 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 | */ | |
| 295 | export 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 { | |
| 294 | 303 | if (typeof data !== "object" || data === null) return undefined; |
| 295 | 304 | const { jsonrpc, id, method, params } = data as Record<string, unknown>; |
| 296 | 305 | if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined; |
| 297 | 306 | 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; | |
| 301 | 312 | } |
| 302 | 313 | |
| 303 | 314 | /** The empty result a client sends back for a render's request. */ |
| ... | ... | @@ -345,7 +356,7 @@ function rootRule(theme: HtmlRenderTheme): string { |
| 345 | 356 | // opens it as a new window, which the client sends to the browser. A framed |
| 346 | 357 | // page also reports its content height, measured as the server measures it, |
| 347 | 358 | // so its client can fit the frame to the page. |
| 348 | 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);}})();`; | |
| 359 | 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);}})();`; | |
| 349 | 360 | |
| 350 | 361 | function bootstrapMarkup(markup: string): string { |
| 351 | 362 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); |