Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/HtmlRenderFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EnvironmentId } from "@t3tools/contracts";
import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import {
HTML_RENDER_COLUMN_WIDTH,
htmlRenderFileName,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -97,6 +98,7 @@ export function HtmlRenderFrame(props: {
<HtmlRenderDocument
src={src}
title={title}
threadRef={props.threadRef}
className="block size-full"
onContentHeight={setContentHeight}
/>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3052,6 +3052,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm
key={row.htmlRender.attachmentId}
environmentId={ctx.activeThreadEnvironmentId}
htmlRender={row.htmlRender}
threadRef={ctx.threadRef ?? undefined}
onOpen={ctx.onFileOpen}
/>
</div>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/files/AttachmentFilePreview.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -250,6 +252,7 @@ export function AttachmentFilePreview(props: {
title={props.name}
pdf={kind === "pdf"}
htmlRender={props.htmlRender === true}
threadRef={props.threadRef}
/>
) : kind === "audio" ? (
<AudioPreview src={url} name={props.name} onError={() => setError("Unable to load audio.")} />
Expand Down
39 changes: 37 additions & 2 deletions apps/web/src/components/files/BrowserDocumentFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import {
htmlRenderThemeFragment,
htmlRenderThemeMessage,
htmlRenderResult,
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";

/**
Expand All @@ -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 ? (
Expand All @@ -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"
/>
) : (
Expand All @@ -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;
}) {
Expand All @@ -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
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<WorkspaceVideoPreview
Expand Down
9 changes: 7 additions & 2 deletions packages/shared/src/htmlRender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,21 @@ describe("injectHtmlRenderBootstrap", () => {

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(
Expand Down
27 changes: 19 additions & 8 deletions packages/shared/src/htmlRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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. */
Expand Down Expand Up @@ -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");
Expand Down
Loading