Repository navigation
feat: agents can show HTML pages inline in threads #15916
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<object> | 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<WebView<object>>(null); | ||||||||||||||||||||||
| const loadedRef = useRef(false); | ||||||||||||||||||||||
| // The theme the loaded document shows; null until it loads. | ||||||||||||||||||||||
| const shownTheme = useRef<HtmlRenderTheme | null>(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 ( | ||||||||||||||||||||||
| <View style={{ flex: 1 }}> | ||||||||||||||||||||||
| <WebView<object> | ||||||||||||||||||||||
| 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} | ||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High On iOS, a provisional navigation failure leaves 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||||||||||||||||||||||
| 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 : ( | ||||||||||||||||||||||
| <View pointerEvents="none" className="absolute inset-0 items-center justify-center"> | ||||||||||||||||||||||
| <ActivityIndicator /> | ||||||||||||||||||||||
| </View> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| </View> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** 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<string | null>(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); | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
Comment on lines
+209
to
+212
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Retry failure leaves the stale URI and can hide the error.
Proposed fix- void refresh().then((next) => {
- if (next !== null && next !== uri) setUri(next);
- else setFailed(true);
- });
+ void refresh()
+ .then((next) => {
+ if (next !== null && next !== uri) setUri(next);
+ else setFailed(true);
+ })
+ .catch(() => setFailed(true));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <View style={{ marginBottom: ROW_BOTTOM_MARGIN }}> | ||||||||||||||||||||||
| <View style={{ height }}> | ||||||||||||||||||||||
| {uri !== null && !failed ? ( | ||||||||||||||||||||||
| <HtmlRenderWebView | ||||||||||||||||||||||
| key={uri} | ||||||||||||||||||||||
| uri={uri} | ||||||||||||||||||||||
| title={title} | ||||||||||||||||||||||
| nested | ||||||||||||||||||||||
| onLoadError={handleLoadError} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| ) : failed || asset._tag === "Failure" ? ( | ||||||||||||||||||||||
| <Pressable | ||||||||||||||||||||||
| accessibilityRole="button" | ||||||||||||||||||||||
| accessibilityLabel={`Reload ${title}`} | ||||||||||||||||||||||
| className="flex-1 items-center justify-center" | ||||||||||||||||||||||
| onPress={() => { | ||||||||||||||||||||||
| retried.current = false; | ||||||||||||||||||||||
| setFailed(false); | ||||||||||||||||||||||
| if (uri === null) void refresh().then((next) => next !== null && setUri(next)); | ||||||||||||||||||||||
| }} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <Text className="text-sm text-foreground-muted">Page unavailable</Text> | ||||||||||||||||||||||
| </Pressable> | ||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||
| <View className="flex-1 items-center justify-center"> | ||||||||||||||||||||||
| <ActivityIndicator /> | ||||||||||||||||||||||
| </View> | ||||||||||||||||||||||
| )} | ||||||||||||||||||||||
| {uri !== null && !failed ? ( | ||||||||||||||||||||||
| <Pressable | ||||||||||||||||||||||
| accessibilityRole="button" | ||||||||||||||||||||||
| accessibilityLabel={`Open ${title}`} | ||||||||||||||||||||||
| hitSlop={8} | ||||||||||||||||||||||
| className="absolute right-1.5 top-1.5 h-7 w-7 items-center justify-center rounded-full border border-border/60 bg-surface/80" | ||||||||||||||||||||||
| onPress={() => | ||||||||||||||||||||||
| navigation.navigate("ThreadAttachment", { | ||||||||||||||||||||||
| environmentId: String(props.environmentId), | ||||||||||||||||||||||
| threadId: String(props.threadId), | ||||||||||||||||||||||
| attachmentId, | ||||||||||||||||||||||
| name: fileName, | ||||||||||||||||||||||
| mimeType: "text/html", | ||||||||||||||||||||||
| sizeBytes: "0", | ||||||||||||||||||||||
| htmlRender: "1", | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <SymbolView | ||||||||||||||||||||||
| name="arrow.up.left.and.arrow.down.right" | ||||||||||||||||||||||
| size={12} | ||||||||||||||||||||||
| tintColor={props.iconColor} | ||||||||||||||||||||||
| type="monochrome" | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| </Pressable> | ||||||||||||||||||||||
| ) : null} | ||||||||||||||||||||||
| </View> | ||||||||||||||||||||||
| </View> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
threads/HtmlRenderWebView.tsx:111External top-frame navigations triggered before the first load completes are rendered inside the inline WebView instead of being opened externally. The
!loadedRef.currentbranch returnstrue, andopenLinkalso refuses to calltryOpenExternalUrlwhile loading; remove this loading gate and route non-props.uritop-frame URLs throughopenLink.const openLink = (url: string) => { - if (loadedRef.current && /^https?:/i.test(url)) void tryOpenExternalUrl(url, "html-render"); + if (/^https?:/i.test(url)) void tryOpenExternalUrl(url, "html-render"); }; const scrollable = !props.nested || overflows; return ( @@ if ( request.isTopFrame === false || - !loadedRef.current || withoutFragment(request.url) === withoutFragment(props.uri) ) {🚀 Reply "fix it for me" or copy this AI Prompt for your agent: