diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index 23adc5cddb12..a8e428564eda 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -24,6 +24,7 @@ import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { SourceFileSurface } from "./SourceFileSurface"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; +import { HtmlRenderWebView } from "../threads/HtmlRenderWebView"; /** * Both the thread stack and the new-task sheet stack register this screen, so a chip in a @@ -38,6 +39,8 @@ export type AttachmentFileRouteParams = { readonly sizeBytes: string; /** Present for a draft attachment, which may still live only on this device. */ readonly draftKey?: string; + /** Present for a page an agent published with `html_render`, which takes the app theme. */ + readonly htmlRender?: "1"; }; type AttachmentFileScreenProps = StaticScreenProps; @@ -54,6 +57,7 @@ function AttachmentDocumentBody(props: { readonly document: ReturnType; readonly name: string; readonly environmentId: EnvironmentId | null; + readonly htmlRender: boolean; readonly nativeViewer: "pending" | "open" | "unavailable" | null; readonly nativeError: string | null; readonly onOpenNative: () => void; @@ -141,7 +145,11 @@ function AttachmentDocumentBody(props: { return ; } if (document.kind === "html") { - return ; + return props.htmlRender ? ( + + ) : ( + + ); } return ( @@ -339,7 +347,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { }, [menuActions], ); - const subtitle = `${draftKey ? "Draft attachment" : "Attachment"} · ${formatAttachmentSize(sizeBytes)}`; + const subtitle = `${draftKey ? "Draft attachment" : "Attachment"}${sizeBytes > 0 ? ` · ${formatAttachmentSize(sizeBytes)}` : ""}`; return ( @@ -405,6 +413,7 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { document={document} name={params.name} environmentId={environmentId} + htmlRender={params.htmlRender === "1"} nativeViewer={nativeViewer} nativeError={nativeError} onOpenNative={() => { diff --git a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx new file mode 100644 index 000000000000..3712854ddd58 --- /dev/null +++ b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx @@ -0,0 +1,273 @@ +import { useNavigation } from "@react-navigation/native"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + htmlRenderFileName, + htmlRenderFrameHeight, + htmlRenderThemeFragment, + htmlRenderThemeMessage, + type HtmlRenderReference, + type HtmlRenderTheme, +} from "@t3tools/shared/htmlRender"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Platform, Pressable, View, type ColorValue } from "react-native"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; + +const ROW_BOTTOM_MARGIN = 8; + +/** A render row is its frame's height plus spacing; the page's content never sizes it. */ +export function htmlRenderRowHeight(frameHeight: number) { + return frameHeight + ROW_BOTTOM_MARGIN; +} + +function useHtmlRenderTheme() { + const { themeId, themeAppearance, themeVariables, systemColorsActive } = + useAppearancePreferences(); + return useMemo( + () => + mobileHtmlRenderTheme({ + themeId, + appearance: themeAppearance, + variables: themeVariables, + systemColors: systemColorsActive, + platform: Platform.OS, + }), + [themeId, themeAppearance, themeVariables, systemColorsActive], + ); +} + +function postTheme(view: WebView | null, theme: HtmlRenderTheme) { + view?.injectJavaScript( + `window.postMessage(${JSON.stringify(htmlRenderThemeMessage(theme))}, "*"); true;`, + ); +} + +const OVERFLOW_MESSAGE_TYPE = "t3-html-render-overflow"; + +// Reports whether the page overflows its frame, so a feed row only takes scroll +// gestures from a page that can use them. +const OVERFLOW_SCRIPT = `(function(){var last;function report(){var d=document.documentElement,b=document.body;var o=Math.max(d.scrollHeight,b?b.scrollHeight:0)>window.innerHeight+1||Math.max(d.scrollWidth,b?b.scrollWidth:0)>window.innerWidth+1;if(o===last)return;last=o;window.ReactNativeWebView.postMessage(JSON.stringify({type:${JSON.stringify(OVERFLOW_MESSAGE_TYPE)},overflow:o}));}report();if(window.ResizeObserver){var r=new ResizeObserver(report);r.observe(document.documentElement);if(document.body)r.observe(document.body);}window.addEventListener("resize",report);})();true;`; + +function readOverflowMessage(data: string) { + try { + const message: unknown = JSON.parse(data); + return typeof message === "object" && + message !== null && + "type" in message && + message.type === OVERFLOW_MESSAGE_TYPE && + "overflow" in message && + typeof message.overflow === "boolean" + ? message.overflow + : null; + } catch { + return null; + } +} + +const withoutFragment = (url: string) => url.split("#", 1)[0]; + +/** + * An agent's HTML page, themed before first paint and kept in step with the app theme. + * The page may move within its own document; links leave for the browser. + */ +export function HtmlRenderWebView(props: { + readonly uri: string; + readonly title: string; + /** Inside the feed, the page takes scroll gestures only when it overflows its frame. */ + readonly nested: boolean; + readonly onLoadError?: () => void; +}) { + const theme = useHtmlRenderTheme(); + const [initialTheme] = useState(theme); + const [generation, setGeneration] = useState(0); + const [loaded, setLoaded] = useState(false); + const [overflows, setOverflows] = useState(false); + const webView = useRef>(null); + const loadedRef = useRef(false); + // The theme the loaded document shows; null until it loads. + const shownTheme = useRef(null); + const source = useMemo( + () => ({ uri: props.uri + htmlRenderThemeFragment(initialTheme) }), + [props.uri, initialTheme], + ); + useEffect(() => { + if (shownTheme.current === null || shownTheme.current === theme) return; + shownTheme.current = theme; + postTheme(webView.current, theme); + }, [theme]); + const restart = () => { + loadedRef.current = false; + shownTheme.current = null; + setLoaded(false); + setOverflows(false); + setGeneration((value) => value + 1); + }; + const openLink = (url: string) => { + if (loadedRef.current && /^https?:/i.test(url)) void tryOpenExternalUrl(url, "html-render"); + }; + const scrollable = !props.nested || overflows; + return ( + + + key={generation} + ref={webView} + source={source} + accessibilityLabel={props.title} + style={{ flex: 1, backgroundColor: "transparent" }} + setSupportMultipleWindows={false} + allowsInlineMediaPlayback + automaticallyAdjustContentInsets={!props.nested} + bounces={!props.nested} + showsVerticalScrollIndicator={!props.nested} + showsHorizontalScrollIndicator={!props.nested} + scrollEnabled={scrollable} + nestedScrollEnabled={props.nested && overflows} + overScrollMode={props.nested ? "never" : "always"} + onShouldStartLoadWithRequest={(request) => { + if ( + request.isTopFrame === false || + !loadedRef.current || + withoutFragment(request.url) === withoutFragment(props.uri) + ) { + return true; + } + openLink(request.url); + return false; + }} + onOpenWindow={(event) => openLink(event.nativeEvent.targetUrl)} + onLoadEnd={() => { + loadedRef.current = true; + setLoaded(true); + shownTheme.current = theme; + if (theme !== initialTheme) postTheme(webView.current, theme); + }} + onError={props.onLoadError} + onHttpError={props.onLoadError} + onContentProcessDidTerminate={restart} + onRenderProcessGone={restart} + {...(props.nested + ? { + injectedJavaScript: OVERFLOW_SCRIPT, + onMessage: (event: WebViewMessageEvent) => { + const overflow = readOverflowMessage(event.nativeEvent.data); + if (overflow !== null) setOverflows(overflow); + }, + } + : {})} + /> + {loaded ? null : ( + + + + )} + + ); +} + +/** A completed `html_render` call in the thread feed: the page itself, at a fixed height. */ +export function ThreadHtmlRender(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly render: HtmlRenderReference; + /** The feed's content width; the frame's height follows the page's measured height there. */ + readonly frameWidth: number; + readonly iconColor: ColorValue; +}) { + const navigation = useNavigation(); + const { attachmentId, title } = props.render; + const height = htmlRenderFrameHeight(props.render, props.frameWidth); + const fileName = htmlRenderFileName(title); + const resource = useMemo( + () => ({ + _tag: "attachment" as const, + attachmentId, + fileName, + mimeType: "text/html", + disposition: "inline" as const, + }), + [attachmentId, fileName], + ); + const asset = useAssetUrlState(props.environmentId, resource); + const refresh = useRefreshAssetUrl(props.environmentId, resource); + // Signed URLs are re-minted periodically; following them would reload the page. + const [uri, setUri] = useState(null); + if (uri === null && asset._tag === "Success") setUri(asset.url); + const [failed, setFailed] = useState(false); + const retried = useRef(false); + const handleLoadError = () => { + if (retried.current) { + setFailed(true); + return; + } + retried.current = true; + void refresh().then((next) => { + if (next !== null && next !== uri) setUri(next); + else setFailed(true); + }); + }; + + return ( + + + {uri !== null && !failed ? ( + + ) : failed || asset._tag === "Failure" ? ( + { + retried.current = false; + setFailed(false); + if (uri === null) void refresh().then((next) => next !== null && setUri(next)); + }} + > + Page unavailable + + ) : ( + + + + )} + {uri !== null && !failed ? ( + + navigation.navigate("ThreadAttachment", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + attachmentId, + name: fileName, + mimeType: "text/html", + sizeBytes: "0", + htmlRender: "1", + }) + } + > + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 0f55d36c0110..61acf94ad63d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -183,6 +183,8 @@ import { import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; +import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; +import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { assetEnvironment, @@ -1536,6 +1538,8 @@ function renderFeedEntry( readonly userBubbleMaxWidth: number; /** Width assistant markdown lays out in, so images can size their frame before layout. */ readonly markdownContentWidth: number; + /** Width full-bleed rows (HTML renders) lay out in. */ + readonly contentWidth: number; readonly threadTitle: string; }, ) { @@ -1574,6 +1578,18 @@ function renderFeedEntry( return ; } + if (entry.type === "html-render") { + return ( + + ); + } + if (entry.type === "work-toggle") { return ( { + if (entry.type === "html-render") { + return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth)); + } if (workRowSizing.fixedRowHeight === undefined) { return undefined; } @@ -2902,7 +2921,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return undefined; } }, - [expandedWorkRows, workRowSizing.fixedRowHeight], + [contentWidth, expandedWorkRows, workRowSizing.fixedRowHeight], ); // Disclosures can mount existing offscreen rows as well as new work rows. @@ -2946,6 +2965,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { themeAppearance, userBubbleMaxWidth, markdownContentWidth, + contentWidth, threadTitle: props.threadTitle, skills: props.skills, workspaceRoot: props.workspaceRoot, @@ -2982,6 +3002,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { themeAppearance, userBubbleMaxWidth, markdownContentWidth, + contentWidth, onCopyWorkRow, markdownLinkHandlers, onPressPreview, diff --git a/apps/mobile/src/lib/htmlRenderTheme.test.ts b/apps/mobile/src/lib/htmlRenderTheme.test.ts new file mode 100644 index 000000000000..bca76dbc3447 --- /dev/null +++ b/apps/mobile/src/lib/htmlRenderTheme.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { mobileHtmlRenderTheme } from "./htmlRenderTheme"; +import { getMobileThemeVariables } from "./mobileTheme"; + +describe("mobileHtmlRenderTheme", () => { + it("paints the page with the feed's own canvas", () => { + const variables = { + ...getMobileThemeVariables("grove", "dark"), + "--color-thread-canvas": "#101010", + }; + const theme = (platform: string) => + mobileHtmlRenderTheme({ + themeId: "grove", + appearance: "dark", + variables, + systemColors: false, + platform, + }); + expect(theme("android").variables["--background"]).toBe("#101010"); + expect(theme("ios").variables["--background"]).toBe(variables["--color-screen"]); + }); + + it("takes Material You's system roles over the default palette", () => { + const variables = { + ...getMobileThemeVariables("t3-code", "light"), + "--color-primary": "#6750A4FF", + "--color-foreground": "#1D1B20FF", + }; + const theme = mobileHtmlRenderTheme({ + themeId: "material-you", + appearance: "light", + variables, + systemColors: true, + platform: "android", + }); + expect(theme.variables["--accent"]).toBe("#6750A4FF"); + expect(theme.variables["--foreground"]).toBe("#1D1B20FF"); + }); +}); diff --git a/apps/mobile/src/lib/htmlRenderTheme.ts b/apps/mobile/src/lib/htmlRenderTheme.ts new file mode 100644 index 000000000000..f8a28345217f --- /dev/null +++ b/apps/mobile/src/lib/htmlRenderTheme.ts @@ -0,0 +1,62 @@ +import { HTML_RENDER_DEFAULT_FONTS, htmlRenderTheme } from "@t3tools/shared/htmlRender"; + +import { + DEFAULT_MOBILE_THEME_ID, + getMobileThemeColors, + nativeColors, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemeVariables, +} from "./mobileTheme"; + +/** + * The theme an HTML render styles against. Its background is the feed's own canvas. + * Material You has no shared palette: it starts from the default one and takes the + * roles its system colors replace. WebViews cannot load the app's bundled fonts, so + * renders use system stacks. + */ +export function mobileHtmlRenderTheme(input: { + readonly themeId: MobileThemeId; + readonly appearance: MobileThemeAppearance; + readonly variables: MobileThemeVariables; + readonly systemColors: boolean; + readonly platform: string; +}) { + const { variables: v } = input; + const palette = getMobileThemeColors( + input.themeId === "material-you" ? DEFAULT_MOBILE_THEME_ID : input.themeId, + input.appearance, + ); + const colors = nativeColors( + input.systemColors + ? { + ...palette, + text: v["--color-foreground"], + textMuted: v["--color-foreground-secondary"], + muted: v["--color-subtle"], + mutedForeground: v["--color-foreground-muted"], + surface: v["--color-card"], + surfaceRaised: v["--color-card-alt"], + surfaceOverlay: v["--color-card-alt"], + border: v["--color-border"], + input: v["--color-input-border"], + focus: v["--color-focus"], + accent: v["--color-primary"], + accentForeground: v["--color-primary-foreground"], + accentSurface: v["--color-inline-skill-background"], + accentSurfaceForeground: v["--color-inline-skill-foreground"], + messageAction: v["--color-primary"], + messageActionForeground: v["--color-primary-foreground"], + secondary: v["--color-secondary"], + secondaryForeground: v["--color-secondary-foreground"], + errorSurface: v["--color-danger"], + errorForeground: v["--color-danger-foreground"], + codeBackground: v["--color-md-code-bg"], + codeForeground: v["--color-md-code-text"], + } + : palette, + ); + // Android paints the thread on its own canvas role; iOS uses the screen. + const canvas = v[input.platform === "android" ? "--color-thread-canvas" : "--color-screen"]; + return htmlRenderTheme({ ...colors, canvas }, input.appearance, HTML_RENDER_DEFAULT_FONTS); +} diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 80453f61a011..4159232fe7ab 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -123,7 +123,7 @@ export function themeColorToNativeColor(value: string): string { : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } -function nativeColors(colors: ThemeColors): ThemeColors { +export function nativeColors(colors: ThemeColors): ThemeColors { return Object.fromEntries( Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]), ) as ThemeColors; diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts index bea0943ab563..0963044b9ffb 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -6,6 +6,7 @@ const ExternalUrlTarget = Schema.Literals([ "markdown-link", "pull-request", "provider-auth", + "html-render", ]); export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5471f24704a9..1cf78cc29f3f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2375,3 +2375,71 @@ it.each(["provider_error", "usage_limit"] as const)( }); }, ); + +describe("html renders", () => { + const page = { attachmentId: "attachment-page", title: "Revenue", height: 320 }; + const renderCall = ( + overrides: Partial> = {}, + ): OrchestrationV2TurnItem => ({ + ...base("item-render", "2026-06-20T00:00:02.500Z", 2), + type: "dynamic_tool", + toolName: "mcp__t3-code__html_render", + input: { title: page.title }, + output: { htmlRender: page }, + ...overrides, + }); + const laterCommand = { + ...command("2026-06-20T00:00:02.800Z"), + id: TurnItemId.make("item-command-later"), + ordinal: 3, + }; + const feed = () => + buildThreadFeed([ + projected(userMessage(), 0), + projected(command(), 1), + projected(renderCall(), 2), + projected(laterCommand, 3), + projected(assistantMessage("2026-06-20T00:00:04.000Z"), 4), + ]); + const latestRun = { + runId, + status: "completed" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:04.000Z", + }; + + it("shows a completed render in place, outside the work log", () => { + const expanded = deriveThreadFeedPresentation(feed(), latestRun, new Set([runId])); + expect(expanded.map((entry) => entry.type)).toEqual([ + "message", + "run-fold", + "work-toggle", + "html-render", + "work-toggle", + "message", + ]); + expect(expanded[3]).toMatchObject({ type: "html-render", render: page, runId }); + expect(expanded[2]?.continuesWorkLog).toBeUndefined(); + }); + + it("keeps a render visible and in order when its run folds", () => { + const collapsed = deriveThreadFeedPresentation(feed(), latestRun, new Set()); + expect(collapsed.map((entry) => entry.type)).toEqual([ + "message", + "run-fold", + "html-render", + "message", + ]); + }); + + it("leaves running, failed and errored renders in the work log", () => { + for (const call of [ + renderCall({ status: "running", output: null }), + renderCall({ status: "failed" }), + renderCall({ output: { isError: true, htmlRender: page } }), + ]) { + const entries = buildThreadFeed([projected(call, 0)]); + expect(entries.map((entry) => entry.type)).toEqual(["activity-group"]); + } + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index a77491795704..86c4184d1c1f 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -57,7 +57,8 @@ import { formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; +import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; +import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; import * as DateTime from "effect/DateTime"; export type PendingApproval = ThreadPendingApproval; @@ -155,12 +156,20 @@ type RawThreadFeedEntry = readonly createdAt: string; readonly runId: RunId | null; readonly activity: ThreadFeedActivity; + } + | { + /** A page a completed `html_render` call published, shown in place of its work row. */ + readonly type: "html-render"; + readonly id: string; + readonly createdAt: string; + readonly runId: RunId | null; + readonly render: HtmlRenderReference; }; export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean }; type ThreadFeedEntryContent = - | Extract + | Extract | { readonly type: "activity-group"; readonly id: string; @@ -1020,7 +1029,7 @@ function deriveThreadFeedRunFolds( const runId = entry.type === "message" && entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) - : entry.type === "activity-group" + : entry.type === "activity-group" || entry.type === "html-render" ? (entry.runId ?? runlessKey) : null; if (!runId) continue; @@ -1072,6 +1081,7 @@ function deriveThreadFeedRunFolds( (entry) => entry.id !== firstAssistantId && entry.id !== terminalAssistantId && + entry.type !== "html-render" && !( entry.type === "activity-group" && entry.activities.some( @@ -1134,7 +1144,7 @@ const trailingReasoningGroups = new WeakMap, + entry: Exclude, tail: boolean, ) { if (entry.type !== "activity-group") return entry; @@ -1700,6 +1710,23 @@ export function buildThreadFeed( continue; } const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt); + // A running or failed render stays an ordinary work row. + const render = + item.type === "dynamic_tool" && item.status === "completed" + ? htmlRenderFromToolItem(item) + : undefined; + if (render) { + const entry: RawThreadFeedEntry = { + type: "html-render", + id: `html-render:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, + createdAt, + runId: item.runId, + render, + }; + projectedEntriesCache.set(row, { attemptId, entry }); + entries.push(entry); + continue; + } if (item.type === "user_message" || item.type === "assistant_message") { const updatedAt = DateTime.formatIso(item.updatedAt); const entry: RawThreadFeedEntry = { diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 4cc9c919623f..1a952d1a6344 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -16,6 +16,7 @@ import { parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, sweepStalePendingAttachments, + threadHtmlRenderAttachmentIds, } from "./attachmentStore.ts"; describe("attachmentStore", () => { @@ -219,3 +220,27 @@ describe("attachmentStore", () => { } }); }); + +describe("threadHtmlRenderAttachmentIds", () => { + it("returns pages this thread published and skips other threads' and failed calls", () => { + const own = createAttachmentId("thread-a", "html")!; + const other = createAttachmentId("thread-b", "html")!; + const render = (attachmentId: string, extra = {}) => ({ + toolName: "mcp__t3-code__html_render", + output: [ + { + type: "text", + text: JSON.stringify({ htmlRender: { attachmentId, title: "x", height: 300 }, ...extra }), + }, + ], + }); + expect( + threadHtmlRenderAttachmentIds("thread-a", [ + render(own), + render(other), + render(createAttachmentId("thread-a", "html")!, { isError: true }), + { toolName: "mcp__t3-code__html_preview", output: render(own).output }, + ]), + ).toEqual([own]); + }); +}); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 41977931b048..c959e8f3a2d0 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -4,6 +4,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; +import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; import { normalizeAttachmentRelativePath, @@ -114,6 +115,25 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string return match[1]?.toLowerCase() ?? null; } +/** + * Attachments a thread's `html_render` tool calls published. Only ids minted + * for this thread count, so deleting a fork never removes its source's pages. + */ +export function threadHtmlRenderAttachmentIds( + threadId: string, + items: Iterable<{ readonly toolName: string | null | undefined; readonly output?: unknown }>, +) { + const segment = toSafeThreadAttachmentSegment(threadId); + if (segment === null) return []; + return Array.from(items).flatMap((item) => { + const attachmentId = htmlRenderFromToolItem(item)?.attachmentId; + return attachmentId !== undefined && + parseThreadSegmentFromAttachmentId(attachmentId) === segment + ? [attachmentId] + : []; + }); +} + /** Null for attachment types this build does not know; callers skip those. */ export function attachmentRelativePath(attachment: ChatAttachment): string | null { switch (attachment.type) { diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts new file mode 100644 index 000000000000..79c243c09b56 --- /dev/null +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -0,0 +1,219 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { + HTML_RENDER_MEASURE_FONTS, + HTML_RENDER_MEASURE_WIDTHS, + htmlRenderTheme, +} from "@t3tools/shared/htmlRender"; +import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as HtmlRender from "./HtmlRender.ts"; +import * as PreviewBrowser from "./PreviewBrowser.ts"; + +// Real-browser tests run only when this names a chrome-headless-shell, for +// example one T3 installed under /tools/chrome-headless-shell. +const TEST_BROWSER_ENV = "T3CODE_TEST_HEADLESS_SHELL"; + +const htmlRenderLayer = (executable?: string) => + HtmlRender.layer.pipe( + Layer.provide( + Layer.succeed( + PreviewBrowser.PreviewBrowser, + PreviewBrowser.PreviewBrowser.of({ + executable: + executable === undefined + ? Effect.die("This test has no preview browser.") + : Effect.succeed(executable), + installed: Effect.succeed(Option.fromUndefinedOr(executable)), + }), + ), + ), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-html-render-" })), + Layer.provideMerge(NodeServices.layer), + ); +const testLayer = htmlRenderLayer(); + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +describe("HtmlRender", () => { + it.effect("inlines local images by absolute path and leaves URLs and relative paths alone", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const htmlRender = yield* HtmlRender.HtmlRender; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-images-" }); + const png = path.join(directory, "shot.png"); + const svg = path.join(directory, "logo.svg"); + yield* fileSystem.writeFile(png, PNG_BYTES); + yield* fileSystem.writeFileString(svg, ""); + const kept = [ + "https://example.com/a.png", + "//cdn.example.com/b.png", + "./c.png", + "data:image/png;base64,AAAA", + ]; + + const prepared = yield* htmlRender.prepare( + [ + "Shots", + `
`, + ``, + ...kept.map((src) => ``), + "", + ].join(""), + ); + + const pngUri = `data:image/png;base64,${Encoding.encodeBase64(PNG_BYTES)}`; + const svgUri = `data:image/svg+xml;base64,${Encoding.encodeBase64("")}`; + expect(prepared).toContain(``); + expect(prepared).toContain(`url(${svgUri})`); + expect(prepared).toContain(`['${pngUri}', \`${svgUri}\`]`); + expect(prepared).not.toContain(directory); + for (const src of kept) expect(prepared).toContain(``); + // The theme bootstrap opens the head, ahead of the page's own markup. + expect(prepared.indexOf("")).toBeLessThan(prepared.indexOf('x"; + const injected = injectHtmlRenderBootstrap(html); + const themeAt = injected.indexOf('`, + ``, + ].join(""); +} + +/** + * Inserts the theme bootstrap at the start of the document head, so a page's + * own styles and scripts come after it. + */ +export function injectHtmlRenderBootstrap(html: string): string { + const markup = bootstrapMarkup(html); + const headOpen = /]*)?>/i.exec(html); + if (headOpen) { + const at = headOpen.index + headOpen[0].length; + return html.slice(0, at) + markup + html.slice(at); + } + const htmlOpen = /]*)?>/i.exec(html); + if (htmlOpen) { + const at = htmlOpen.index + htmlOpen[0].length; + return `${html.slice(0, at)}${markup}${html.slice(at)}`; + } + const doctype = /^\s*]*>/i.exec(html); + if (doctype) { + const at = doctype[0].length; + return `${html.slice(0, at)}${markup}${html.slice(at)}`; + } + return `${markup}${html}`; +} diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index d85104cf4bc1..ff3e9ef404e7 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -58,7 +58,9 @@ export type T3McpToolSummaryAction = | "watch-pr" | "unwatch-pr" | "browser" - | "device"; + | "device" + | "html-preview" + | "html-render"; export interface T3McpToolDefinition { readonly displayName: string; @@ -284,6 +286,8 @@ const T3_MCP_TOOLS: Readonly> = { "attachment-discard", ), t3_thread_send_attachments: tool(["Send", "Sending", "Sent", "attachments"], "attachment-send"), + html_preview: tool(["Preview", "Previewing", "Previewed", "an HTML page"], "html-preview"), + html_render: tool(["Render", "Rendering", "Rendered", "an HTML page"], "html-render"), }; /** @@ -327,6 +331,12 @@ function resolveT3McpToolName(value: string): string | null { return Object.hasOwn(T3_MCP_TOOLS, candidate) ? candidate : null; } +/** The bare T3 tool name (`html_render`) for any provider's spelling of it. */ +export function resolveT3McpToolId(toolName: string | null | undefined): string | null { + const name = toolName == null ? null : resolveT3McpToolName(toolName); + return name !== null && Object.hasOwn(T3_MCP_TOOLS, name) ? name : null; +} + export function resolveT3McpToolDefinition( toolName: string | null | undefined, ): T3McpToolDefinition | null { diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index 255fcbf47163..3eff550fd66b 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -1,5 +1,12 @@ import * as Predicate from "effect/Predicate"; +import { + HTML_RENDER_TOOL_NAME, + readHtmlRenderReference, + type HtmlRenderReference, +} from "./htmlRender.ts"; +import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; + const MAX_PARSED_BYTES = 16_384; const MAX_METADATA_BYTES = 8_192; const MAX_ID_LENGTH = 256; @@ -27,6 +34,7 @@ interface CompactToolOutput { taskId?: string; scheduledTaskId?: string; status?: "rolled_back"; + htmlRender?: HtmlRenderReference; thread?: { threadId: string }; threads?: Array<{ threadId?: string; status?: "rolled_back" }>; } @@ -110,6 +118,8 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un if (id !== undefined) output[key] = id; } if (data.status === "rolled_back") output.status = "rolled_back"; + const htmlRender = readHtmlRenderReference(data.htmlRender); + if (htmlRender !== undefined) output.htmlRender = htmlRender; const nestedThreadId = Predicate.isObject(data.thread) ? boundedId(data.thread.threadId) : undefined; @@ -153,6 +163,16 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un return Object.keys(output).length === 0 ? undefined : output; } +/** The page a completed `html_render` tool call published, if this item is one. */ +export function htmlRenderFromToolItem(item: { + readonly toolName: string | null | undefined; + readonly output?: unknown; +}): HtmlRenderReference | undefined { + if (resolveT3McpToolId(item.toolName) !== HTML_RENDER_TOOL_NAME) return undefined; + const output = compactDynamicToolOutput(item.output); + return output?.isError ? undefined : output?.htmlRender; +} + /** Some providers report completion even when command output describes a failure. */ export function toolOutputIndicatesFailure(text: string): boolean { return (