diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json
index 2da491dae..75aa28c61 100644
--- a/packages/app-bundle/manifest.json
+++ b/packages/app-bundle/manifest.json
@@ -804,11 +804,14 @@
"packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e",
"packages/app/src/components/session/preview-file-target.test.ts": "d9a341349d8922b0369f3fdf911cce35d96344962ad442267e9078106d266db3",
"packages/app/src/components/session/preview-file-target.ts": "11738d77fc1e86a4a968b65f3fb342f51792b6a2c2d57a08114197ff2e82a51b",
- "packages/app/src/components/session/preview-file-view.tsx": "06c26f47cc47f71264c2b60904e91a356b4a63fc1a6c9a7acf971a4d51c9e4a9",
+ "packages/app/src/components/session/preview-file-view.tsx": "27153914e75d6e596fe56ebe6556653bc589c7f6c31fcdb24e37618814133268",
"packages/app/src/components/session/preview-find-in-sidebar.test.ts": "68cdc2d9743b3c3a6a6c1fab6cbd362d27b4f7179cba973cba67d10f01b35970",
"packages/app/src/components/session/preview-frontmatter-card.tsx": "00f439e3bf482673dfb06c0ce3464c0f51985093ed57b321cdebd38d452f2e5d",
"packages/app/src/components/session/preview-frontmatter.test.ts": "93ec9f7f4c604d8ed8ee6009779c425bffecfca331a122a7a700b588a8ca3c0c",
"packages/app/src/components/session/preview-frontmatter.ts": "036250edd8c2886ee8f8ac0723f2b9a41d363898006a53f47bc44b8194c5a3d3",
+ "packages/app/src/components/session/preview-html-view.tsx": "ebae43522dbc103fb7033a9aea06a4b04ac5bc0f4a6396d7ef428c2f71c21e99",
+ "packages/app/src/components/session/preview-html.test.ts": "f3e6a867e039a29dcf218f15be81f05aed7a5b4ccfb4d3575e20fc308544fc60",
+ "packages/app/src/components/session/preview-html.ts": "2df2bef7acc7c125d0fb795386500dc092e260bc15b65940869e912dedb03f5d",
"packages/app/src/components/session/preview-human-write-gate.ts": "4c4b1cf258a06b0012d722add0125ecb77a0f4e463dd95d193f21e5e73541098",
"packages/app/src/components/session/preview-latex-compile.test.ts": "bfd86b8e7ae2da8060aaabdb167ec8521983777040c7201a7bbb7d7e3c877ac6",
"packages/app/src/components/session/preview-latex-compile.ts": "b9388ca0ba698b023e73d823b6064428937fdacc4ce63f1682018f49387307b8",
@@ -967,13 +970,13 @@
"packages/session-ui/src/v2/components/editor-core.test.ts": "b2fa5f3a2395862fe59692e346568299acf76ad639d0d1336b09fec9cdaf4694",
"packages/session-ui/src/v2/components/editor-core.ts": "1457c2e2e223f6bbe0a1a5c8c9fd45974ebd86cefc90442ce6df02ae79d5946c",
"packages/session-ui/src/v2/components/file-name-picker.test.tsx": "1ce9741e315b549165d390c74b5885a43580aa70e24de987f9ba8388cd521b41",
- "packages/session-ui/src/v2/components/markdown-utils.test.ts": "10e1034293bf385cf50d860bf1667d8370abc61dcee0c5a93295f21f5f55876a",
- "packages/session-ui/src/v2/components/markdown-utils.ts": "3bc47cfed5b99ab23f857d5324be3189f3a7e800d991103d72ef1c2f970b910b",
+ "packages/session-ui/src/v2/components/markdown-utils.test.ts": "0e6540ac2ce2b33f38fa498807be9327764ab7deffdbeb59d2365b298dee85ee",
+ "packages/session-ui/src/v2/components/markdown-utils.ts": "8e42aaaef7dbb8f3c762de04fd8515c932f89a78e7da2a2d5a58499b136d7347",
"packages/session-ui/src/v2/components/preview-editor-helpers.test.ts": "6e242cabfded1ce2edddfa3c181fb709ce4408448bc2c1251707297ef633d64e",
"packages/session-ui/src/v2/components/preview-editor-helpers.ts": "7f61e97e54286af1bbf5e88538e9c73995eb30f19868c6f43694422205aeda4b",
"packages/session-ui/src/v2/components/preview-editor.test.ts": "b8da9320a594aaa680ed3848612fcea66f41c0a046a673f0cf6155c2a0b697dd",
"packages/session-ui/src/v2/components/preview-editor.tsx": "96cfbfbbe06cc797f71ac667a29ece3c44a192c9cd94d91e90f79359cdab932b",
- "packages/session-ui/src/v2/components/preview-file-routing.test.ts": "6422e3b36de18a19b101f7321ae2b93e5bcd19a5d1b6e588b64f34af91098d6d",
+ "packages/session-ui/src/v2/components/preview-file-routing.test.ts": "2367ca08b0b9668b98ad2dade01016932d92ec0f0725f210eb29639ce6c6bb7d",
"packages/session-ui/src/v2/components/preview-nav-state.test.ts": "cec09a8b3c08ab16581d3d5b6ff704584b3c87d81c25e8af20f6f00272f3593f",
"packages/session-ui/src/v2/components/preview-nav-state.ts": "ccaeb94d1ab39271f5dcd49c935c8fc7b54d4ffbd66e1352e4d797c9205553fc",
"packages/session-ui/src/v2/components/preview-view-state.ts": "4ec961c7720417166dcccd45f0d4702afec664dfe7f4d0dd8668bb9b1d8af5e6",
@@ -1099,10 +1102,10 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
- "extracted_at": "2026-11-01T16:01:02.158Z",
+ "extracted_at": "2026-11-06T18:48:23.336Z",
"per_package": {
"packages/app": {
- "A": 309,
+ "A": 312,
"M": 166,
"D": 0
},
@@ -1148,7 +1151,7 @@
}
},
"counts": {
- "overlay_total": 1077,
+ "overlay_total": 1080,
"deletions": 7,
"server_coupled": 0
},
@@ -1954,6 +1957,9 @@
"packages/app/src/components/session/preview-frontmatter-card.tsx": "A",
"packages/app/src/components/session/preview-frontmatter.test.ts": "A",
"packages/app/src/components/session/preview-frontmatter.ts": "A",
+ "packages/app/src/components/session/preview-html-view.tsx": "A",
+ "packages/app/src/components/session/preview-html.test.ts": "A",
+ "packages/app/src/components/session/preview-html.ts": "A",
"packages/app/src/components/session/preview-human-write-gate.ts": "A",
"packages/app/src/components/session/preview-latex-compile.test.ts": "A",
"packages/app/src/components/session/preview-latex-compile.ts": "A",
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx
index 498b03a85..654c16ad1 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx
@@ -10,6 +10,9 @@
* the CM6 editor. Async fileType signal detects binary/error/oversize after
* file.read().
*
+ * #2141: .html/.htm files render in a scripts-off sandboxed frame
+ * (PreviewHtmlView) with the same Preview/Edit toggle as markdown.
+ *
* #934: images render via data URI from the SDK binary response. PDFs render
* via PDF.js (pdfjs-dist) to canvas — no iframe, no browser plugin. Save dot
* moved to parent (SessionPreviewTab); zoom controls moved here to share
@@ -45,6 +48,8 @@ import {
} from "./preview-latex-compile"
import { splitFrontmatter } from "./preview-frontmatter"
import { FrontmatterCard } from "./preview-frontmatter-card"
+import { PreviewHtmlView } from "./preview-html-view"
+import { popOutHtmlMessage } from "./preview-html"
// ---------------------------------------------------------------------------
// Helpers
@@ -55,7 +60,7 @@ function getExtension(path: string): string {
return dot > 0 ? path.slice(dot).toLowerCase() : ""
}
-type FileCategory = "markdown" | "image" | "pdf" | "text"
+type FileCategory = "markdown" | "html" | "image" | "pdf" | "text"
/**
* Sync, extension-based file classification (phase 1).
@@ -67,6 +72,7 @@ function getFileCategory(path: string): FileCategory {
if ((RENDERABLE_EXTENSIONS.markdown as readonly string[]).includes(ext)) return "markdown"
if ((RENDERABLE_EXTENSIONS.images as readonly string[]).includes(ext)) return "image"
if ((RENDERABLE_EXTENSIONS.pdf as readonly string[]).includes(ext)) return "pdf"
+ if ((RENDERABLE_EXTENSIONS.html as readonly string[]).includes(ext)) return "html"
return "text"
}
@@ -502,12 +508,12 @@ export function PreviewFileView(props: {
// ─── Render ────────────────────────────────────────────────────────────
- const showModeToggle = () => category() === "markdown"
+ const showModeToggle = () => category() === "markdown" || category() === "html"
// Zoom is disabled in edit mode — pill disappears entirely
const isEditing = () => {
const cat = category()
- if (cat === "markdown") return mode() === "edit"
+ if (cat === "markdown" || cat === "html") return mode() === "edit"
if (cat === "image" || cat === "pdf") return false
return true // text/code files are always in edit mode
}
@@ -712,6 +718,12 @@ export function PreviewFileView(props: {
link.remove()
}
+ // #2143: the pop-out rides its own bridge kind (open-external stays https-only).
+ const popOutHtml = () => {
+ if (window.parent === window) return
+ window.parent.postMessage(popOutHtmlMessage(selfAbsolute()), "*")
+ }
+
return (
+ {/* Pop-out (#2143): open the rendered page in the system browser. The
+ extension opens only an existing local .html/.htm file and names a refusal. */}
+
+
+
+
+
{/* Zoom controls: [editable %] [reset] [+ over -] */}
+ {/* HTML (#2141): rendered in a scripts-off sandboxed frame; Edit = source */}
+
+ props.viewState?.()?.editor}
+ onViewStateChange={(editor) => props.onViewStateChange?.({ editor })}
+ />
+ }
+ >
+ sdk().client.file.read({ path }).then((result) => result.data as any)}
+ onExternalContent={(text) => {
+ // #2142: a poll found new page text; never clobber unsaved Source edits
+ if (unsavedContent() !== null) return
+ setFileContent(text)
+ }}
+ />
+
+
+
{/* Text files: open directly in edit mode */}
Promise<
+ { type?: string; content?: string; encoding?: string; mimeType?: string } | null | undefined
+>
+
+export function PreviewHtmlView(props: {
+ filePath: string
+ html: () => string
+ zoom: () => number
+ readFile: HtmlFileRead
+ /** The session the view belongs to — preview-file opens target it (#1648). */
+ sessionID?: string
+ /** Whether this pane is the visible one; polling stops when it is not (#2142). */
+ active?: () => boolean
+ /** New page text found by a poll (#2142); the parent decides whether to apply it. */
+ onExternalContent?: (text: string) => void
+}) {
+ const platform = usePlatform()
+ const sdk = useSDK()
+ const [srcdoc, setSrcdoc] = createSignal(undefined)
+ const [refresh, setRefresh] = createSignal(0)
+ let inlined = new Map()
+ let inlinedChars = 0
+ let frame: HTMLIFrameElement | undefined
+ let generation = 0
+ let savedScroll: { x: number; y: number } | undefined
+
+ // ─── Prepare + inline ────────────────────────────────────────────────
+ const readImages = async (files: string[]) => {
+ const reads = await Promise.all(
+ files.map((file) =>
+ props
+ .readFile(file)
+ .then((result) => [file, dataUrlFromRead(result, file)] as const)
+ .catch(() => [file, undefined] as const),
+ ),
+ )
+ const urls = new Map()
+ let budget = MAX_INLINE_CHARS
+ for (const [file, url] of reads) {
+ if (!url || url.length > budget) continue
+ budget -= url.length
+ urls.set(file, url)
+ }
+ return urls
+ }
+
+ const render = async (html: string, path: string) => {
+ const mine = ++generation
+ const prepared = prepareHtml(html, path)
+ const unique = [...new Set(prepared.images.map((image) => image.path))].slice(0, MAX_INLINE_IMAGES)
+ const urls = await readImages(unique)
+ if (mine !== generation) return
+ inlined = urls
+ inlinedChars = [...urls.values()].reduce((total, url) => total + url.length, 0)
+ for (const image of prepared.images) {
+ const url = urls.get(image.path)
+ if (url) image.element.setAttribute(image.attribute, url)
+ else {
+ image.element.removeAttribute(image.attribute)
+ if (!image.element.getAttribute("title")) image.element.setAttribute("title", `Could not load ${image.path}`)
+ }
+ }
+ // keep the reader's place across a re-render
+ try {
+ const win = frame?.contentWindow
+ if (win) savedScroll = { x: win.scrollX, y: win.scrollY }
+ } catch {}
+ setSrcdoc(serializeHtml(prepared.doc))
+ }
+
+ createEffect(
+ on(
+ () => [props.html(), props.filePath, refresh()] as const,
+ ([html, path]) => {
+ void render(html, path)
+ },
+ ),
+ )
+ onCleanup(() => {
+ generation++
+ })
+
+ // ─── Live refresh (#2142) ────────────────────────────────────────────
+ // A watcher change to an inlined image re-renders (the page's own changes
+ // reach us through the parent's watcher re-read).
+ createEffect(() => {
+ const unsub = sdk().event.listen((e) => {
+ if (e.details.type !== "file.watcher.updated") return
+ const details = e.details.properties as Record | undefined
+ const changed = typeof details?.file === "string" ? details.file : undefined
+ if (!changed || (details?.event !== "change" && details?.event !== "add")) return
+ for (const asset of inlined.keys()) {
+ if (watcherPathMatches(changed, asset)) {
+ setRefresh((n) => n + 1)
+ return
+ }
+ }
+ })
+ onCleanup(unsub)
+ })
+
+ // A visible page re-reads itself, and (under the cap) its images, on a timer:
+ // the watcher never sees a file a script rewrites outside the project.
+ let polling = false
+ const poll = async () => {
+ if (polling) return
+ const visible = typeof document === "undefined" || document.visibilityState !== "hidden"
+ if (!shouldPollHtml({ active: props.active?.() ?? true, visible })) return
+ polling = true
+ try {
+ const mine = generation
+ const page = await props.readFile(props.filePath).catch(() => undefined)
+ if (mine !== generation) return
+ if (page && page.type === "text" && typeof page.content === "string" && page.content !== props.html()) {
+ props.onExternalContent?.(page.content)
+ return
+ }
+ if (inlined.size === 0 || inlinedChars > ASSET_POLL_MAX_CHARS) return
+ const fresh = await readImages([...inlined.keys()])
+ if (mine !== generation) return
+ if (assetsChanged(inlined, fresh)) setRefresh((n) => n + 1)
+ } finally {
+ polling = false
+ }
+ }
+ onMount(() => {
+ const timer = setInterval(() => void poll(), HTML_POLL_MS)
+ onCleanup(() => clearInterval(timer))
+ })
+
+ // ─── Frame wiring ────────────────────────────────────────────────────
+ const frameDocument = () => {
+ try {
+ return frame?.contentDocument ?? undefined
+ } catch {
+ return undefined
+ }
+ }
+
+ const applyZoom = () => {
+ const doc = frameDocument()
+ if (doc?.documentElement) doc.documentElement.style.zoom = `${props.zoom() / 100}`
+ }
+ createEffect(on(() => props.zoom(), applyZoom, { defer: true }))
+
+ const openPreview = (path: string) => {
+ window.postMessage({ source: "amicode", kind: "preview-file", path, sessionID: props.sessionID }, "*")
+ }
+
+ const onFrameClick = (event: MouseEvent) => {
+ const target = event.target as Element | null
+ const anchor = target?.closest?.("a[href], area[href], a") as Element | null
+ if (!anchor) return
+ const href = anchor.getAttribute("href") ?? anchor.getAttribute("xlink:href")
+ if (href === null) return
+ event.preventDefault()
+ if (event.type !== "click") return
+ const action = classifyLink(href, props.filePath)
+ if (action.kind === "external") platform.openExternal(action.url)
+ else if (action.kind === "preview") openPreview(action.path)
+ else if (action.kind === "anchor") {
+ const doc = frameDocument()
+ const el = doc?.getElementById(action.id) ?? doc?.getElementsByName(action.id)[0]
+ el?.scrollIntoView({ block: "start" })
+ }
+ }
+
+ const onLoad = () => {
+ const doc = frameDocument()
+ if (!doc) return
+ applyZoom()
+ doc.addEventListener("click", onFrameClick)
+ doc.addEventListener("auxclick", onFrameClick)
+ // forms cannot submit (no allow-forms); swallow the attempt quietly
+ doc.addEventListener("submit", (event) => event.preventDefault())
+ const scroll = savedScroll
+ savedScroll = undefined
+ if (scroll) frame?.contentWindow?.scrollTo(scroll.x, scroll.y)
+ }
+
+ return (
+
+ )
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.test.ts
new file mode 100644
index 000000000..37e56bc39
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, test } from "bun:test"
+import {
+ ASSET_POLL_MAX_CHARS,
+ HTML_POLL_MS,
+ MAX_INLINE_CHARS,
+ assetsChanged,
+ classifyLink,
+ dataUrlFromRead,
+ imageMimeFor,
+ isHtmlFile,
+ popOutHtmlMessage,
+ prepareHtml,
+ resolveLocalRef,
+ serializeHtml,
+ shouldPollHtml,
+ watcherPathMatches,
+} from "./preview-html"
+
+// #2141: Preview renders .html pages in a scripts-off sandboxed frame. These
+// helpers prepare the page (strip what would run/refresh/fetch, collect local
+// images for inlining) and route link clicks.
+
+describe("isHtmlFile", () => {
+ test("matches .html and .htm, case-insensitive", () => {
+ expect(isHtmlFile("status.html")).toBe(true)
+ expect(isHtmlFile("a/b/Dashboard.HTM")).toBe(true)
+ })
+ test("does not match other files or a bare dotfile", () => {
+ expect(isHtmlFile("notes.md")).toBe(false)
+ expect(isHtmlFile("page.html.bak")).toBe(false)
+ expect(isHtmlFile(".html")).toBe(false)
+ })
+})
+
+describe("resolveLocalRef", () => {
+ test("relative refs resolve against the page's folder", () => {
+ expect(resolveLocalRef("plots/f1.png", "/v/sessions/s.dashboard.html")).toBe("/v/sessions/plots/f1.png")
+ expect(resolveLocalRef("./f1.png", "/v/a/s.html")).toBe("/v/a/f1.png")
+ expect(resolveLocalRef("../img/x.svg", "/v/a/s.html")).toBe("/v/img/x.svg")
+ })
+ test("a project-relative page gives a project-relative path", () => {
+ expect(resolveLocalRef("f.png", "data/run/status.html")).toBe("data/run/f.png")
+ expect(resolveLocalRef("f.png", "status.html")).toBe("f.png")
+ })
+ test("absolute paths and file:// URLs are local; percent-escapes decode", () => {
+ expect(resolveLocalRef("/abs/My%20Plot.png", "/x/p.html")).toBe("/abs/My Plot.png")
+ expect(resolveLocalRef("file:///Users/j/run/iter_3.png", "/x/p.html")).toBe("/Users/j/run/iter_3.png")
+ })
+ test("query and fragment are dropped", () => {
+ expect(resolveLocalRef("f.png?v=3#top", "/x/p.html")).toBe("/x/f.png")
+ })
+ test("remote, data, blob, mailto, javascript, protocol-relative, empty and fragments are not local", () => {
+ for (const ref of [
+ "https://cdn.example/x.png",
+ "http://x/y.png",
+ "data:image/png;base64,AAAA",
+ "blob:abc",
+ "mailto:a@b",
+ "javascript:alert(1)",
+ "//cdn.example/x.png",
+ "",
+ " ",
+ "#section",
+ null,
+ undefined,
+ ]) {
+ expect(resolveLocalRef(ref as string, "/x/p.html")).toBeUndefined()
+ }
+ })
+})
+
+describe("classifyLink", () => {
+ test("http(s) and protocol-relative links open externally", () => {
+ expect(classifyLink("https://github.com/harmoniqs/Coro.jl/issues/7", "/x/p.html")).toEqual({
+ kind: "external",
+ url: "https://github.com/harmoniqs/Coro.jl/issues/7",
+ })
+ expect(classifyLink("//example.com/a", "/x/p.html")).toEqual({ kind: "external", url: "https://example.com/a" })
+ })
+ test("relative and file:// links open the target in Preview", () => {
+ expect(classifyLink("slice_routed_v3.mp4", "/d/carried/status.html")).toEqual({
+ kind: "preview",
+ path: "/d/carried/slice_routed_v3.mp4",
+ })
+ expect(classifyLink("file:///d/log.md#x", "/x/p.html")).toEqual({ kind: "preview", path: "/d/log.md" })
+ })
+ test("fragments scroll within the page", () => {
+ expect(classifyLink("#log", "/x/p.html")).toEqual({ kind: "anchor", id: "log" })
+ })
+ test("javascript:, mailto:, bare # and empty hrefs do nothing", () => {
+ for (const href of ["javascript:void(0)", "mailto:a@b", "#", "", null]) {
+ expect(classifyLink(href as string, "/x/p.html")).toEqual({ kind: "ignore" })
+ }
+ })
+})
+
+describe("prepareHtml", () => {
+ const page = `
+
+
+
+
+