1import { useAtomValue } from "@effect/atom-react";
2import {
3 type KeybindingCommand,
4 type KeybindingShortcut,
5 MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
6 type ScopedThreadRef,
7 THREAD_JUMP_KEYBINDING_COMMANDS,
8} from "@t3tools/contracts";
9import {
10 type HtmlRenderShortcut,
11 htmlRenderShortcutsMessage,
12 htmlRenderThemeFragment,
13 htmlRenderThemeMessage,
14 htmlRenderResult,
15 readHtmlRenderContentSize,
16 readHtmlRenderKeyDown,
17 readHtmlRenderLinkRequest,
18} from "@t3tools/shared/htmlRender";
19import {
20 isMacPlatform,
21 matchesKeybindingShortcut,
22 normalizeEventKey,
23} from "@t3tools/shared/keybindings";
24import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from "react";
25
26import { useOpenLink } from "~/browser/useOpenLink";
27import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
28import { shortcutConflictKey } from "~/keybindings";
29import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
30import { readLocalApi } from "~/localApi";
31import { cn } from "~/lib/utils";
32import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
33
34/**
35 * Chromium's viewer opens with its own toolbar, a thumbnail rail and a small
36 * zoom. The panel header is the only chrome we want, so ask for the page
37 * alone, fitted to the panel width. Pinch and keyboard zoom, scrolling, text
38 * selection and find still work inside the frame.
39 */
40const PDF_VIEWER_FRAGMENT = "#toolbar=0&view=FitH";
41
42/**
43 * Shortcuts that still work while a focused HTML render holds the keyboard.
44 * The page reports the keys, and a page script can report any key it likes,
45 * so only commands that change what the reader sees are here, never one that
46 * changes a thread.
47 */
48const HTML_RENDER_SHORTCUT_COMMANDS: ReadonlySet<KeybindingCommand> = new Set([
49 "commandPalette.toggle",
50 "filePicker.toggle",
51 "projectSearch.toggle",
52 "chat.find",
53 "sidebar.toggle",
54 "rightPanel.toggle",
55 "rightPanel.toggleMaximized",
56 "threadPanel.toggle",
57 "terminal.toggle",
58 "preview.toggle",
59 "navigation.back",
60 "navigation.forward",
61 "thread.previous",
62 "thread.next",
63 ...THREAD_JUMP_KEYBINDING_COMMANDS,
64 // Share Cmd/Ctrl+digit with thread jumps, and act only while the picker is open.
65 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
66]);
67
68export const isPdfPreviewFile = (path: string): boolean =>
69 /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
70
71/**
72 * Renders an HTML or PDF document from its URL. HTML runs in a sandboxed frame
73 * with an opaque origin, so a page cannot reach the app's session or storage.
74 * The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts.
75 * An agent's HTML render also wears the app theme.
76 */
77export function BrowserDocumentFrame(props: {
78 readonly src: string;
79 readonly title: string;
80 readonly pdf: boolean;
81 readonly htmlRender?: boolean;
82 /** The thread an HTML render's links open beside. */
83 readonly threadRef?: ScopedThreadRef | undefined;
84}) {
85 const className = "min-h-0 flex-1 border-0 bg-white";
86 return props.pdf ? (
87 // oxlint-disable-next-line react/iframe-missing-sandbox -- the built-in PDF viewer needs an unsandboxed frame.
88 <iframe
89 key={props.src}
90 src={`${props.src}${PDF_VIEWER_FRAGMENT}`}
91 title={props.title}
92 className={className}
93 />
94 ) : props.htmlRender ? (
95 <HtmlRenderDocument
96 key={props.src}
97 src={props.src}
98 title={props.title}
99 threadRef={props.threadRef}
100 className="min-h-0 flex-1"
101 />
102 ) : (
103 <iframe
104 key={props.src}
105 src={props.src}
106 title={props.title}
107 className={className}
108 sandbox="allow-scripts allow-forms allow-popups allow-downloads"
109 />
110 );
111}
112
113/**
114 * A sandboxed agent HTML render in the app theme. The page reads the theme from
115 * its URL fragment before first paint, then follows changes posted to its
116 * bootstrap. The first URL is kept for the frame's lifetime: signed asset URLs
117 * re-mint while it stays mounted, and a new src would reload the page.
118 */
119export function HtmlRenderDocument(props: {
120 readonly src: string;
121 readonly title: string;
122 readonly className?: string;
123 /** The thread the page's links open beside, as a chat link would. */
124 readonly threadRef?: ScopedThreadRef | undefined;
125 readonly onContentSize?: (
126 size: NonNullable<ReturnType<typeof readHtmlRenderContentSize>>,
127 ) => void;
128}) {
129 const theme = useHtmlRenderTheme();
130 const frameRef = useRef<HTMLIFrameElement>(null);
131 const [src] = useState(() => `${props.src.split("#", 1)[0]}${htmlRenderThemeFragment(theme)}`);
132 const [loaded, setLoaded] = useState(false);
133 const postTheme = () => {
134 frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*");
135 };
136 useEffect(postTheme, [theme]);
137 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
138 // Chat also resolves project scripts from the thread's own environment.
139 const environmentKeybindings = useAtomValue(
140 serverEnvironment.configValueAtom(props.threadRef?.environmentId ?? null),
141 )?.keybindings;
142 // The app resolves a replayed key in its live context, so every allowed
143 // binding counts whatever its `when`, and a key that any binding gives to
144 // another command stays with the page. Only Cmd/Ctrl chords: a printable key
145 // would type into the composer instead.
146 const { shortcuts, blocked } = useMemo(() => {
147 const mac = isMacPlatform(navigator.platform);
148 const resolve = (shortcut: KeybindingShortcut): HtmlRenderShortcut => ({
149 key: shortcut.key,
150 metaKey: shortcut.metaKey || (shortcut.modKey && mac),
151 ctrlKey: shortcut.ctrlKey || (shortcut.modKey && !mac),
152 shiftKey: shortcut.shiftKey,
153 altKey: shortcut.altKey,
154 });
155 const blocked = [...keybindings, ...(environmentKeybindings ?? [])]
156 .filter((binding) => !HTML_RENDER_SHORTCUT_COMMANDS.has(binding.command))
157 .map((binding) => resolve(binding.shortcut));
158 const shortcuts = new Map<string, HtmlRenderShortcut>();
159 for (const { command, shortcut } of keybindings) {
160 if (
161 HTML_RENDER_SHORTCUT_COMMANDS.has(command) &&
162 (shortcut.metaKey || shortcut.ctrlKey || shortcut.modKey)
163 ) {
164 shortcuts.set(shortcutConflictKey(shortcut), resolve(shortcut));
165 }
166 }
167 return { shortcuts: [...shortcuts.values()], blocked };
168 }, [environmentKeybindings, keybindings]);
169 const postShortcuts = () => {
170 frameRef.current?.contentWindow?.postMessage(
171 htmlRenderShortcutsMessage(shortcuts, blocked),
172 "*",
173 );
174 };
175 useEffect(postShortcuts, [blocked, shortcuts]);
176 // The page reports the key and code, and a page script can report any pair,
177 // so a key that could match another command's binding is never replayed. A
178 // named key (Backspace, Enter) must be the shortcut's own, since some app
179 // handlers read `event.key` directly. A dead key matches by its code.
180 const isShortcut = useEffectEvent(
181 (key: NonNullable<ReturnType<typeof readHtmlRenderKeyDown>>) => {
182 const matches = (shortcut: HtmlRenderShortcut) =>
183 matchesKeybindingShortcut(key, { ...shortcut, modKey: false }, navigator.platform);
184 const allowed = shortcuts.filter(matches);
185 return (
186 allowed.length > 0 &&
187 !blocked.some(matches) &&
188 (key.key.length === 1 ||
189 key.key === "Dead" ||
190 allowed.some((shortcut) => shortcut.key === normalizeEventKey(key.key)))
191 );
192 },
193 );
194 const openChangeRequestLink = useOpenChangeRequestLink(props.threadRef);
195 const openLink = useOpenLink(props.threadRef);
196 // Same rules as a chat link: a pull request opens in the right panel, any
197 // other link follows "Open links in", and Cmd/Ctrl-click goes to the system
198 // browser. Without a thread there is no panel, so it goes to the browser.
199 const open = useEffectEvent((url: string, modifiers: { metaKey: boolean; ctrlKey: boolean }) => {
200 // The modifier's destination needs no saved setting, so it never waits on one.
201 if (modifiers.metaKey || modifiers.ctrlKey) {
202 void readLocalApi()
203 ?.shell.openExternal(url)
204 .catch((cause: unknown) => console.error(cause));
205 return;
206 }
207 if (
208 props.threadRef !== undefined &&
209 openChangeRequestLink(
210 { ...modifiers, preventDefault: () => undefined, stopPropagation: () => undefined },
211 url,
212 undefined,
213 props.threadRef.environmentId,
214 )
215 ) {
216 return;
217 }
218 void openLink(url, { event: modifiers }).catch((cause: unknown) => console.error(cause));
219 });
220 // The page cannot open windows itself (an inline page runs unopened, and
221 // desktop sends any window to the browser). It asks the client, which opens
222 // the link only while this frame has focus and the reader has just used the
223 // app. A page can take focus by script, so this stops opens on load, not a
224 // page that waits for the reader's next click or key.
225 useEffect(() => {
226 const openLink = (event: MessageEvent) => {
227 const frame = frameRef.current;
228 const request = readHtmlRenderLinkRequest(event.data);
229 if (
230 request === undefined ||
231 frame === null ||
232 event.source !== frame.contentWindow ||
233 document.activeElement !== frame ||
234 navigator.userActivation?.isActive === false
235 ) {
236 return;
237 }
238 open(request.url, request);
239 frame.contentWindow?.postMessage(htmlRenderResult(request.id), "*");
240 };
241 window.addEventListener("message", openLink);
242 return () => window.removeEventListener("message", openLink);
243 }, []);
244 // A focused frame takes every key, so app shortcuts would stop until the
245 // reader clicked out. The page passes on the chords posted to it, which replay
246 // on the frame element and bubble to the app's window listeners. A report is
247 // gated like a link and must be one of those chords.
248 useEffect(() => {
249 const replayKey = (event: MessageEvent) => {
250 const frame = frameRef.current;
251 const key = readHtmlRenderKeyDown(event.data);
252 if (
253 key === undefined ||
254 frame === null ||
255 event.source !== frame.contentWindow ||
256 document.activeElement !== frame ||
257 navigator.userActivation?.isActive === false
258 ) {
259 return;
260 }
261 if (!isShortcut(key)) return;
262 frame.dispatchEvent(
263 new KeyboardEvent("keydown", { ...key, bubbles: true, cancelable: true }),
264 );
265 };
266 window.addEventListener("message", replayKey);
267 return () => window.removeEventListener("message", replayKey);
268 }, []);
269 const { onContentSize } = props;
270 // A page posts its size once per change, so listen from the commit that
271 // inserts the frame; a passive effect could run after a fast page's first post.
272 useLayoutEffect(() => {
273 if (onContentSize === undefined) return;
274 const resize = (event: MessageEvent) => {
275 const size = readHtmlRenderContentSize(event.data);
276 if (size !== undefined && event.source === frameRef.current?.contentWindow) {
277 onContentSize(size);
278 }
279 };
280 window.addEventListener("message", resize);
281 return () => window.removeEventListener("message", resize);
282 }, [onContentSize]);
283 return (
284 <iframe
285 ref={frameRef}
286 src={src}
287 title={props.title}
288 // Never allow-same-origin: the opaque origin keeps the page out of the app's session.
289 sandbox="allow-scripts allow-forms"
290 loading="lazy"
291 onLoad={() => {
292 setLoaded(true);
293 // Covers a theme change that landed while the page was loading.
294 postTheme();
295 postShortcuts();
296 }}
297 // A frame whose color scheme differs from its document's paints an opaque
298 // canvas, so the blank document a frame starts with would flash white in
299 // dark mode. Once the page is in, its prefers-color-scheme follows the app.
300 className={cn("border-0 scheme-light", props.className)}
301 style={loaded ? { colorScheme: theme.appearance } : undefined}
302 />
303 );
304}