From 868bca4f5b9a2b5a4aedda4b7f1c7e5ae4945717 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Fri, 6 Nov 2026 13:44:20 -0500 Subject: [PATCH 1/4] feat(app): Preview renders HTML files in a scripts-off sandboxed frame (#2141) .html/.htm open rendered with the markdown Preview/Edit toggle. The page runs in an iframe sandboxed allow-same-origin WITHOUT allow-scripts: scripts, inline handlers and meta refresh never run, while the host routes link clicks (https to the system browser, local links into Preview), applies zoom and keeps scroll across re-renders. Before display the host strips script/base/refresh/ link/embedding elements and inlines local images (relative, absolute, file://; img src and SVG image href) as data URIs read through the engine file API, within a count and size budget. Part of #2140. --- packages/app-bundle/manifest.json | 20 +- .../components/session/preview-file-view.tsx | 37 ++- .../components/session/preview-html-view.tsx | 163 +++++++++++++ .../components/session/preview-html.test.ts | 172 ++++++++++++++ .../src/components/session/preview-html.ts | 223 ++++++++++++++++++ .../src/v2/components/markdown-utils.test.ts | 6 + .../src/v2/components/markdown-utils.ts | 3 + .../components/preview-file-routing.test.ts | 32 +++ 8 files changed, 646 insertions(+), 10 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/preview-html-view.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/preview-html.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 2da491dae..49f19a50f 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": "f15e4d034435eb2a5a02b1629dbfa3e0cd039a3185dc90e3157bdd1e0541872d", "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": "226c792eb9e9b3af7659758fdc9fe21b55438a2d6918430b252b89c6845f16c8", + "packages/app/src/components/session/preview-html.test.ts": "30bc4e5cf0039e22c066c734c4c59d2dd1d623644004bfe7fe65283525c5c9f7", + "packages/app/src/components/session/preview-html.ts": "a00b1ba9ed4ef4d05f3109485abe0fb168e28513e9b39cf11b08d1c97b437842", "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": "a4a3f6534ad0f4c855c79836cefd9bc897e6938d8418d4a40ceb8d1c3b01bcda", "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:44:13.041Z", "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..d0a33a111 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,7 @@ import { } from "./preview-latex-compile" import { splitFrontmatter } from "./preview-frontmatter" import { FrontmatterCard } from "./preview-frontmatter-card" +import { PreviewHtmlView } from "./preview-html-view" // --------------------------------------------------------------------------- // Helpers @@ -55,7 +59,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 +71,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 +507,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 } @@ -1125,6 +1130,32 @@ export function PreviewFileView(props: { + {/* 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)} + /> + + + {/* 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 + /** Called after each render with the local files the page inlined (#2142). */ + onAssetsChange?: (paths: string[]) => void + /** Bumped by the parent to re-render even when the page text is unchanged + * (a referenced image changed on disk, #2142). */ + refreshKey?: () => number +}) { + const platform = usePlatform() + const [srcdoc, setSrcdoc] = createSignal(undefined) + let frame: HTMLIFrameElement | undefined + let generation = 0 + let savedScroll: { x: number; y: number } | undefined + + // ─── Prepare + inline ──────────────────────────────────────────────── + 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 = new Map() + let budget = MAX_INLINE_CHARS + const reads = await Promise.all( + unique.map((file) => + props + .readFile(file) + .then((result) => [file, dataUrlFromRead(result, file)] as const) + .catch(() => [file, undefined] as const), + ), + ) + if (mine !== generation) return + for (const [file, url] of reads) { + if (!url || url.length > budget) continue + budget -= url.length + urls.set(file, url) + } + 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)) + props.onAssetsChange?.(unique) + } + + createEffect( + on( + () => [props.html(), props.filePath, props.refreshKey?.() ?? 0] as const, + ([html, path]) => { + void render(html, path) + }, + ), + ) + onCleanup(() => { + generation++ + }) + + // ─── 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 ( + + + + + gh` + + test("removes everything that could run, refresh, re-base or embed", () => { + const html = serializeHtml(prepareHtml(page, "/v/s/status.html").doc) + expect(html).not.toContain(" { + const html = serializeHtml(prepareHtml(page, "/v/s/status.html").doc) + expect(html).toContain("

Status

") + expect(html).toContain("body{color:#222}") + expect(html).toContain('href="https://github.com"') + expect(html.startsWith("")).toBe(true) + }) + + test("collects local images (img src, SVG image href) for inlining, in document order", () => { + const { images } = prepareHtml(page, "/v/s/status.html") + expect(images.map((i) => [i.attribute, i.path])).toEqual([ + ["src", "/v/s/plots/f1.png"], + ["src", "/v/s/small.png"], + ["href", "/v/s/inner.png"], + ]) + }) + + test("keeps https and data image sources, drops other fetching attributes", () => { + const { doc } = prepareHtml(page, "/v/s/status.html") + const srcs = Array.from(doc.querySelectorAll("img")).map((i) => i.getAttribute("src")) + expect(srcs).toContain("https://example.com/r.png") + expect(srcs).toContain("data:image/png;base64,AA") + expect(doc.getElementById("bad")!.hasAttribute("src")).toBe(false) + const video = doc.querySelector("video")! + expect(video.hasAttribute("src")).toBe(false) + expect(video.hasAttribute("poster")).toBe(false) + expect(doc.querySelector("img[srcset]")).toBeNull() + }) +}) + +describe("dataUrlFromRead", () => { + test("base64 image reads become data URIs, preferring the server's mime", () => { + expect(dataUrlFromRead({ type: "binary", encoding: "base64", content: "AAA", mimeType: "image/png" }, "a.png")).toBe( + "data:image/png;base64,AAA", + ) + expect(dataUrlFromRead({ type: "binary", encoding: "base64", content: "AAA" }, "a.JPG")).toBe( + "data:image/jpeg;base64,AAA", + ) + }) + test("an SVG read as text becomes a URL-encoded SVG data URI", () => { + expect(dataUrlFromRead({ type: "text", content: "" }, "p.svg")).toBe( + "data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E", + ) + }) + test("unusable reads give undefined", () => { + expect(dataUrlFromRead(null, "a.png")).toBeUndefined() + expect(dataUrlFromRead({ type: "text", content: "hello" }, "a.txt")).toBeUndefined() + expect(dataUrlFromRead({ type: "binary", encoding: "base64", content: "" }, "a.png")).toBeUndefined() + expect(dataUrlFromRead({ type: "binary", encoding: "base64", content: "AA", mimeType: "video/mp4" }, "a.mp4")).toBeUndefined() + }) + test("mime guessing covers the plot formats", () => { + expect(imageMimeFor("x.svg")).toBe("image/svg+xml") + expect(imageMimeFor("x.webp")).toBe("image/webp") + expect(imageMimeFor("x.mp4")).toBeUndefined() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts new file mode 100644 index 000000000..af685e96b --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts @@ -0,0 +1,223 @@ +/** + * preview-html — pure helpers behind the Preview tab's HTML renderer (#2141). + * + * A previewed `.html` page is shown in an iframe sandboxed with + * `allow-same-origin` and WITHOUT `allow-scripts`: nothing in the page runs, + * and the host can still reach into the frame to route link clicks, apply zoom + * and keep scroll. That pairing is only safe while scripts stay off — never + * add `allow-scripts` to that frame. + * + * Before display the host prepares the page here: elements that would run + * code, refresh, re-base or embed are removed, and local image references are + * collected so the view can inline them as data URIs (a srcdoc document + * resolves relative URLs against the app's own origin, so a relative `src` + * would otherwise hit the engine server instead of the file beside the page). + * + * @module + */ + +export const HTML_EXTENSIONS = [".html", ".htm"] as const + +export function isHtmlFile(path: string): boolean { + const dot = path.lastIndexOf(".") + if (dot <= 0) return false + return (HTML_EXTENSIONS as readonly string[]).includes(path.slice(dot).toLowerCase()) +} + +/** Inlining budget: a pathological page must not stall the panel. */ +export const MAX_INLINE_IMAGES = 64 +export const MAX_INLINE_CHARS = 24_000_000 + +/** + * Elements removed outright. Scripts never run in the sandbox anyway; removing + * them keeps the console quiet. `meta[http-equiv]` covers refresh (the host + * owns reloading), `base` would re-point every relative URL, and the embedding + * elements would load content the host does not prepare. `link` is dropped + * because a relative stylesheet/icon would be requested from the app origin. + */ +const STRIPPED_SELECTOR = [ + "script", + "noscript", + "base", + "meta[http-equiv]", + "link", + "iframe", + "frame", + "frameset", + "object", + "embed", + "applet", + "portal", +].join(", ") + +/** Attributes that would make the frame fetch something the host did not prepare. */ +const FETCHING_ATTRIBUTES = ["src", "srcset", "poster", "data", "background"] as const + +const SCHEME = /^[a-z][a-z0-9+.-]*:/i + +/** Join + normalize a POSIX-style path (handles `.` and `..`, keeps a leading `/`). */ +function normalizePath(path: string): string { + const absolute = path.startsWith("/") + const out: string[] = [] + for (const part of path.split("/")) { + if (part === "" || part === ".") continue + if (part === "..") { + if (out.length > 0 && out[out.length - 1] !== "..") out.pop() + else if (!absolute) out.push("..") + continue + } + out.push(part) + } + const joined = out.join("/") + return absolute ? `/${joined}` : joined +} + +function dirname(path: string): string { + const slash = path.lastIndexOf("/") + if (slash < 0) return "" + if (slash === 0) return "/" + return path.slice(0, slash) +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +/** + * Resolve a URL found in the page to a local file path, or undefined when it is + * not local (remote, data, blob, mailto, javascript, protocol-relative, empty, + * or a bare fragment). Relative references resolve against the page's folder; + * the result is relative when the page path is. + */ +export function resolveLocalRef(ref: string | null | undefined, pagePath: string): string | undefined { + const raw = (ref ?? "").trim() + if (raw === "" || raw.startsWith("#") || raw.startsWith("//")) return undefined + if (SCHEME.test(raw)) { + if (!/^file:/i.test(raw)) return undefined + try { + const path = safeDecode(new URL(raw).pathname) + return path ? normalizePath(path) : undefined + } catch { + return undefined + } + } + const bare = raw.split("#")[0]!.split("?")[0]! + if (bare === "") return undefined + const decoded = safeDecode(bare) + if (decoded.startsWith("/")) return normalizePath(decoded) + const base = dirname(pagePath) + return normalizePath(base ? `${base}/${decoded}` : decoded) +} + +export type HtmlLinkAction = + | { kind: "external"; url: string } + | { kind: "preview"; path: string } + | { kind: "anchor"; id: string } + | { kind: "ignore" } + +/** What a click on a link inside the previewed page should do. */ +export function classifyLink(href: string | null | undefined, pagePath: string): HtmlLinkAction { + const raw = (href ?? "").trim() + if (raw === "") return { kind: "ignore" } + if (raw.startsWith("#")) return raw.length > 1 ? { kind: "anchor", id: safeDecode(raw.slice(1)) } : { kind: "ignore" } + if (/^https?:\/\//i.test(raw)) return { kind: "external", url: raw } + if (raw.startsWith("//")) return { kind: "external", url: `https:${raw}` } + const local = resolveLocalRef(raw, pagePath) + return local ? { kind: "preview", path: local } : { kind: "ignore" } +} + +export interface PreparedHtml { + /** The cleaned document; image `src` values are rewritten in place by the view. */ + doc: Document + /** Images with a local source, in document order: the element, the attribute + * to rewrite (`src` on ``, `href` on SVG ``), and the file it needs. */ + images: { element: Element; attribute: string; path: string }[] +} + +/** + * Parse and clean a page for display. Parsing with DOMParser never runs + * scripts. Local image references are left for the caller to inline (async + * reads); every other attribute that would fetch from the app origin is removed. + */ +export function prepareHtml(html: string, pagePath: string): PreparedHtml { + const doc = new DOMParser().parseFromString(html, "text/html") + for (const element of Array.from(doc.querySelectorAll(STRIPPED_SELECTOR))) element.remove() + for (const source of Array.from(doc.querySelectorAll("picture source"))) source.remove() + + const images: PreparedHtml["images"] = [] + for (const element of Array.from(doc.querySelectorAll("[src], [srcset], [poster], [data], [background]"))) { + const tag = element.tagName.toLowerCase() + for (const attribute of FETCHING_ATTRIBUTES) { + if (!element.hasAttribute(attribute)) continue + const value = element.getAttribute(attribute) ?? "" + if (tag === "img" && attribute === "src") { + const local = resolveLocalRef(value, pagePath) + if (local) { + images.push({ element, attribute: "src", path: local }) + continue + } + if (/^(https:|data:)/i.test(value.trim())) continue + } else if (attribute === "src" && /^(https:|data:)/i.test(value.trim())) { + continue + } + element.removeAttribute(attribute) + } + } + // SVG (inline SVG plots): href / xlink:href — inline local, keep data:/https:. + for (const element of Array.from(doc.querySelectorAll("image"))) { + for (const attribute of ["href", "xlink:href"]) { + if (!element.hasAttribute(attribute)) continue + const value = element.getAttribute(attribute) ?? "" + const local = resolveLocalRef(value, pagePath) + if (local) images.push({ element, attribute, path: local }) + else if (!/^(https:|data:)/i.test(value.trim())) element.removeAttribute(attribute) + } + } + return { doc, images } +} + +const IMAGE_MIME: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".bmp": "image/bmp", + ".avif": "image/avif", +} + +export function imageMimeFor(path: string): string | undefined { + const dot = path.lastIndexOf(".") + return dot > 0 ? IMAGE_MIME[path.slice(dot).toLowerCase()] : undefined +} + +/** + * A data URI for a file.read() result, or undefined when it is not a usable + * image (unknown type, empty, or a text file that is not SVG). + */ +export function dataUrlFromRead( + result: { type?: string; content?: string; encoding?: string; mimeType?: string } | null | undefined, + path: string, +): string | undefined { + if (!result || typeof result.content !== "string" || result.content === "") return undefined + const guessed = imageMimeFor(path) + if (result.type === "text" && result.encoding !== "base64") { + if (guessed !== "image/svg+xml") return undefined + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(result.content)}` + } + if (result.encoding !== "base64") return undefined + const mime = result.mimeType && result.mimeType.startsWith("image/") ? result.mimeType : guessed + if (!mime) return undefined + return `data:${mime};base64,${result.content}` +} + +/** Serialize the prepared document for `srcdoc`. */ +export function serializeHtml(doc: Document): string { + return `\n${doc.documentElement.outerHTML}` +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/markdown-utils.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/markdown-utils.test.ts index bd49ac661..458fee9cd 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/markdown-utils.test.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/markdown-utils.test.ts @@ -108,6 +108,12 @@ describe("isRenderable", () => { expect(isRenderable("paper.pdf")).toBe(true) }) + test("accepts .html/.htm files (#2141)", () => { + expect(isRenderable("campaign.dashboard.html")).toBe(true) + expect(isRenderable("old/STATUS.HTM")).toBe(true) + expect(RENDERABLE_EXTENSIONS.html).toEqual([".html", ".htm"]) + }) + test("rejects non-renderable files", () => { expect(isRenderable("app.ts")).toBe(false) expect(isRenderable("style.css")).toBe(false) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/markdown-utils.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/markdown-utils.ts index aee8cd3f5..10cb2d6fc 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/markdown-utils.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/markdown-utils.ts @@ -37,6 +37,8 @@ export const RENDERABLE_EXTENSIONS = { markdown: [".md", ".markdown"] as const, images: [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"] as const, pdf: [".pdf"] as const, + // #2141: rendered in a scripts-off sandboxed frame (app preview-html-view.tsx) + html: [".html", ".htm"] as const, } as const /** Flat set of all renderable extensions for O(1) lookup. */ @@ -44,6 +46,7 @@ const ALL_RENDERABLE = new Set([ ...RENDERABLE_EXTENSIONS.markdown, ...RENDERABLE_EXTENSIONS.images, ...RENDERABLE_EXTENSIONS.pdf, + ...RENDERABLE_EXTENSIONS.html, ]) /** diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts index 0ef52c0e5..ddc44fc87 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts @@ -977,3 +977,35 @@ describe("compile button visibility (#1403)", () => { expect(fileViewSrc).toMatch(/[\s\S]{0,1200}data-preview-compile/) }) }) + +// --------------------------------------------------------------------------- +// HTML preview (#2141): rendered in a scripts-off, same-origin sandboxed frame +// --------------------------------------------------------------------------- + +describe("HTML preview routing and sandbox (#2141)", () => { + const fileViewSrc = fs.readFileSync( + path.resolve(__dirname, "../../../../app/src/components/session/preview-file-view.tsx"), + "utf8", + ) + const htmlViewSrc = fs.readFileSync( + path.resolve(__dirname, "../../../../app/src/components/session/preview-html-view.tsx"), + "utf8", + ) + + test("the file view classifies .html/.htm as its own category", () => { + expect(fileViewSrc).toMatch(/RENDERABLE_EXTENSIONS\.html[\s\S]{0,80}return "html"/) + }) + + test("html gets the Preview/Edit toggle and renders through PreviewHtmlView", () => { + expect(fileViewSrc).toMatch(/showModeToggle = \(\) => category\(\) === "markdown" \|\| category\(\) === "html"/) + expect(fileViewSrc).toMatch(/[\s\S]{0,1200} { + const sandbox = htmlViewSrc.match(/sandbox="([^"]*)"/) + expect(sandbox).not.toBeNull() + expect(sandbox![1]).toBe("allow-same-origin") + expect(htmlViewSrc).not.toMatch(/allow-scripts"/) + expect(htmlViewSrc).toContain("srcdoc=") + }) +}) From 51efd7e39efad646600895b4b4ae7a394d8119b2 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Fri, 6 Nov 2026 13:45:46 -0500 Subject: [PATCH 2/4] feat(app): keep rendered HTML live outside the watcher's reach (#2142) The engine watcher only sees the project tree and the agent's own writes, so a dashboard a script rewrites in the personal vault never refreshed. While its pane is active and the window visible, the rendered page re-reads itself every 3 s (new text goes through the parent, which never clobbers unsaved Source edits) and, while the inlined total is under 8 MB, its images; a watcher change to any inlined image re-renders too. Re-renders keep scroll. Part of #2140. --- packages/app-bundle/manifest.json | 12 +-- .../components/session/preview-file-view.tsx | 6 ++ .../components/session/preview-html-view.tsx | 100 +++++++++++++++--- .../components/session/preview-html.test.ts | 42 ++++++++ .../src/components/session/preview-html.ts | 30 ++++++ .../components/preview-file-routing.test.ts | 8 ++ 6 files changed, 176 insertions(+), 22 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 49f19a50f..32fd5b69e 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -804,14 +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": "f15e4d034435eb2a5a02b1629dbfa3e0cd039a3185dc90e3157bdd1e0541872d", + "packages/app/src/components/session/preview-file-view.tsx": "368b9ff84a9e1c89d904e8529eab39c02b581ec803c05cdcb33b19b9b32e4ad7", "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": "226c792eb9e9b3af7659758fdc9fe21b55438a2d6918430b252b89c6845f16c8", - "packages/app/src/components/session/preview-html.test.ts": "30bc4e5cf0039e22c066c734c4c59d2dd1d623644004bfe7fe65283525c5c9f7", - "packages/app/src/components/session/preview-html.ts": "a00b1ba9ed4ef4d05f3109485abe0fb168e28513e9b39cf11b08d1c97b437842", + "packages/app/src/components/session/preview-html-view.tsx": "ebae43522dbc103fb7033a9aea06a4b04ac5bc0f4a6396d7ef428c2f71c21e99", + "packages/app/src/components/session/preview-html.test.ts": "1724e07e7e72e65f35876fb384f4f6e0d207065040ca238f979ff1c4700a5e5f", + "packages/app/src/components/session/preview-html.ts": "bc4a49640c28cc93fc19bcf423d153dc29e07137f84119b41029aff6dfd68c80", "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", @@ -976,7 +976,7 @@ "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": "a4a3f6534ad0f4c855c79836cefd9bc897e6938d8418d4a40ceb8d1c3b01bcda", + "packages/session-ui/src/v2/components/preview-file-routing.test.ts": "490d4d9f83e5b7c73a6b1ae1ced6c5f1218c20b1b9ce683ac6b69d74d606ab9e", "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", @@ -1102,7 +1102,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-11-06T18:44:13.041Z", + "extracted_at": "2026-11-06T18:45:46.036Z", "per_package": { "packages/app": { "A": 312, 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 d0a33a111..805dfda43 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 @@ -1151,7 +1151,13 @@ export function PreviewFileView(props: { html={fileContent} zoom={props.zoom} sessionID={draftScope} + active={props.active} readFile={(path) => 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) + }} />
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-html-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html-view.tsx index 2d028e92c..a3fead267 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-html-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html-view.tsx @@ -11,18 +11,29 @@ * watcher-driven reloads); this view prepares it (preview-html.ts), inlines * local images through the engine's file API, and swaps the srcdoc. * + * #2142: the view also keeps itself live where the engine's watcher cannot + * see — while visible it re-reads the page (handing new text to the parent) + * and, under a size cap, its images; a watcher change to any inlined image + * re-renders too. + * * @module */ -import { createEffect, createSignal, on, onCleanup } from "solid-js" +import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" import { + ASSET_POLL_MAX_CHARS, + HTML_POLL_MS, MAX_INLINE_CHARS, + assetsChanged, MAX_INLINE_IMAGES, classifyLink, dataUrlFromRead, prepareHtml, serializeHtml, + shouldPollHtml, + watcherPathMatches, } from "./preview-html" export type HtmlFileRead = (path: string) => Promise< @@ -36,39 +47,49 @@ export function PreviewHtmlView(props: { readFile: HtmlFileRead /** The session the view belongs to — preview-file opens target it (#1648). */ sessionID?: string - /** Called after each render with the local files the page inlined (#2142). */ - onAssetsChange?: (paths: string[]) => void - /** Bumped by the parent to re-render even when the page text is unchanged - * (a referenced image changed on disk, #2142). */ - refreshKey?: () => number + /** 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 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 = new Map() - let budget = MAX_INLINE_CHARS + const readImages = async (files: string[]) => { const reads = await Promise.all( - unique.map((file) => + files.map((file) => props .readFile(file) .then((result) => [file, dataUrlFromRead(result, file)] as const) .catch(() => [file, undefined] as const), ), ) - if (mine !== generation) return + 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) @@ -83,12 +104,11 @@ export function PreviewHtmlView(props: { if (win) savedScroll = { x: win.scrollX, y: win.scrollY } } catch {} setSrcdoc(serializeHtml(prepared.doc)) - props.onAssetsChange?.(unique) } createEffect( on( - () => [props.html(), props.filePath, props.refreshKey?.() ?? 0] as const, + () => [props.html(), props.filePath, refresh()] as const, ([html, path]) => { void render(html, path) }, @@ -98,6 +118,54 @@ export function PreviewHtmlView(props: { 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 { 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 index 0c08a9336..1497d1928 100644 --- 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 @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" import { + ASSET_POLL_MAX_CHARS, + HTML_POLL_MS, + MAX_INLINE_CHARS, + assetsChanged, classifyLink, dataUrlFromRead, imageMimeFor, @@ -7,6 +11,8 @@ import { prepareHtml, resolveLocalRef, serializeHtml, + shouldPollHtml, + watcherPathMatches, } from "./preview-html" // #2141: Preview renders .html pages in a scripts-off sandboxed frame. These @@ -170,3 +176,39 @@ describe("dataUrlFromRead", () => { expect(imageMimeFor("x.mp4")).toBeUndefined() }) }) + +// #2142: keep the rendered page live when it (or a plot it shows) changes where +// the engine's watcher cannot see — a script writing into the personal vault. +describe("live refresh helpers (#2142)", () => { + test("polls only while the pane is active and the window is visible", () => { + expect(shouldPollHtml({ active: true, visible: true })).toBe(true) + expect(shouldPollHtml({ active: false, visible: true })).toBe(false) + expect(shouldPollHtml({ active: true, visible: false })).toBe(false) + }) + + test("asset change detection compares the inlined data per file", () => { + const before = new Map([ + ["/a/f1.png", "data:image/png;base64,AAA"], + ["/a/f2.png", "data:image/png;base64,BBB"], + ]) + expect(assetsChanged(before, new Map(before))).toBe(false) + expect(assetsChanged(before, new Map([...before, ["/a/f2.png", "data:image/png;base64,CCC"]]))).toBe(true) + // a file that could not be read this time counts as a change (it vanished) + expect(assetsChanged(before, new Map([["/a/f1.png", "data:image/png;base64,AAA"]]))).toBe(true) + // a file that newly became readable counts too + expect(assetsChanged(new Map(), new Map([["/a/f1.png", "x"]]))).toBe(true) + }) + + test("watcher paths match a project-relative or absolute asset path", () => { + expect(watcherPathMatches("/work/data/run/f1.png", "data/run/f1.png")).toBe(true) + expect(watcherPathMatches("/v/s/plots/f1.png", "/v/s/plots/f1.png")).toBe(true) + expect(watcherPathMatches("/work/data/run/xf1.png", "f1.png")).toBe(false) + expect(watcherPathMatches("/work/a.png", "/other/a.png")).toBe(false) + }) + + test("assets are re-polled only while the inlined total stays under the cap", () => { + expect(ASSET_POLL_MAX_CHARS).toBeLessThan(MAX_INLINE_CHARS) + expect(HTML_POLL_MS).toBeGreaterThanOrEqual(1000) + expect(HTML_POLL_MS).toBeLessThanOrEqual(5000) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts index af685e96b..d91754320 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts @@ -221,3 +221,33 @@ export function dataUrlFromRead( export function serializeHtml(doc: Document): string { return `\n${doc.documentElement.outerHTML}` } + +// --------------------------------------------------------------------------- +// Live refresh (#2142) +// --------------------------------------------------------------------------- + +/** How often a visible rendered page re-reads itself (and its images). The + * engine's watcher only sees the project tree and the agent's own writes; a + * dashboard a script rewrites in the personal vault needs this. */ +export const HTML_POLL_MS = 3000 + +/** Images are re-read on a poll only while the inlined total stays under this. */ +export const ASSET_POLL_MAX_CHARS = 8_000_000 + +export function shouldPollHtml(state: { active: boolean; visible: boolean }): boolean { + return state.active && state.visible +} + +/** True when any file's inlined data differs, appeared, or vanished. */ +export function assetsChanged(previous: Map, next: Map): boolean { + if (previous.size !== next.size) return true + for (const [path, url] of previous) if (next.get(path) !== url) return true + return false +} + +/** Watcher events carry absolute paths; an asset path may be project-relative. */ +export function watcherPathMatches(changed: string, asset: string): boolean { + if (changed === asset) return true + if (asset.startsWith("/")) return false + return changed.endsWith(`/${asset}`) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts index ddc44fc87..5cc95994d 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts @@ -1008,4 +1008,12 @@ describe("HTML preview routing and sandbox (#2141)", () => { expect(htmlViewSrc).not.toMatch(/allow-scripts"/) expect(htmlViewSrc).toContain("srcdoc=") }) + + test("live refresh (#2142): the view polls through the gate, and the parent never clobbers unsaved edits", () => { + expect(htmlViewSrc).toContain("shouldPollHtml(") + expect(htmlViewSrc).toContain("setInterval(") + expect(htmlViewSrc).toContain("clearInterval(") + expect(htmlViewSrc).toContain("watcherPathMatches(") + expect(fileViewSrc).toMatch(/onExternalContent=\{\(text\) => \{[\s\S]{0,200}if \(unsavedContent\(\) !== null\) return[\s\S]{0,60}setFileContent\(text\)/) + }) }) From 38b709d65a0e2e2744406e7c8717ec982658f42f Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Fri, 6 Nov 2026 13:48:23 -0500 Subject: [PATCH 3/4] feat: pop a previewed HTML page out to the system browser (#2143) Rendered HTML in Preview gets an 'Open in browser' control. It posts a new bridge kind, open-html-external, so open-external stays https-only; both webview shells forward it, and the extension opens the path with the system handler only when it is an absolute, existing, local .html/.htm file in a local window. Every refusal (relative path, wrong type, missing file, remote window) names itself in a warning; nothing opens. Part of #2140. --- packages/app-bundle/manifest.json | 10 ++-- .../components/session/preview-file-view.tsx | 22 +++++++ .../components/session/preview-html.test.ts | 11 ++++ .../src/components/session/preview-html.ts | 10 ++++ .../components/preview-file-routing.test.ts | 5 ++ packages/extension/src/chat_bridge.ts | 24 ++++++++ packages/extension/src/chat_panel.ts | 4 +- packages/extension/src/deck/shell.ts | 1 + packages/extension/test/chat_bridge.test.ts | 59 +++++++++++++++++++ 9 files changed, 139 insertions(+), 7 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 32fd5b69e..75aa28c61 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -804,14 +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": "368b9ff84a9e1c89d904e8529eab39c02b581ec803c05cdcb33b19b9b32e4ad7", + "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": "1724e07e7e72e65f35876fb384f4f6e0d207065040ca238f979ff1c4700a5e5f", - "packages/app/src/components/session/preview-html.ts": "bc4a49640c28cc93fc19bcf423d153dc29e07137f84119b41029aff6dfd68c80", + "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", @@ -976,7 +976,7 @@ "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": "490d4d9f83e5b7c73a6b1ae1ced6c5f1218c20b1b9ce683ac6b69d74d606ab9e", + "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", @@ -1102,7 +1102,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-11-06T18:45:46.036Z", + "extracted_at": "2026-11-06T18:48:23.336Z", "per_package": { "packages/app": { "A": 312, 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 805dfda43..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 @@ -49,6 +49,7 @@ import { import { splitFrontmatter } from "./preview-frontmatter" import { FrontmatterCard } from "./preview-frontmatter-card" import { PreviewHtmlView } from "./preview-html-view" +import { popOutHtmlMessage } from "./preview-html" // --------------------------------------------------------------------------- // Helpers @@ -717,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 -] */}
{ expect(HTML_POLL_MS).toBeLessThanOrEqual(5000) }) }) + +describe("pop-out message (#2143)", () => { + test("carries the absolute path on its own bridge kind", () => { + expect(popOutHtmlMessage("/v/s/c.dashboard.html")).toEqual({ + source: "amicode", + kind: "open-html-external", + path: "/v/s/c.dashboard.html", + }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts index d91754320..7c8468c2d 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-html.ts @@ -251,3 +251,13 @@ export function watcherPathMatches(changed: string, asset: string): boolean { if (asset.startsWith("/")) return false return changed.endsWith(`/${asset}`) } + +// --------------------------------------------------------------------------- +// Pop-out (#2143) +// --------------------------------------------------------------------------- + +/** The bridge message that asks the extension to open the page in the system + * browser. Its own kind: the open-external lane stays https-only. */ +export function popOutHtmlMessage(absolutePath: string): { source: "amicode"; kind: "open-html-external"; path: string } { + return { source: "amicode", kind: "open-html-external", path: absolutePath } +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts index 5cc95994d..73e43a977 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/preview-file-routing.test.ts @@ -1016,4 +1016,9 @@ describe("HTML preview routing and sandbox (#2141)", () => { expect(htmlViewSrc).toContain("watcherPathMatches(") expect(fileViewSrc).toMatch(/onExternalContent=\{\(text\) => \{[\s\S]{0,200}if \(unsavedContent\(\) !== null\) return[\s\S]{0,60}setFileContent\(text\)/) }) + + test("pop-out (#2143): an html-only control posts the dedicated bridge kind with the absolute path", () => { + expect(fileViewSrc).toMatch(/[\s\S]{0,400}data-preview-open-browser/) + expect(fileViewSrc).toContain("popOutHtmlMessage(selfAbsolute())") + }) }) diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 3a27d3d52..5683be026 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -463,6 +463,30 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return true; } + // #2143: Preview's pop-out — open a previewed HTML page in the system + // browser (a second monitor for a live campaign dashboard). A dedicated kind + // so open-external stays https-only; restricted to an existing, absolute, + // local .html/.htm file so the bridge can never launch an arbitrary local + // file through the OS handler. In a remote window the path lives on the + // remote machine, not the one showing the browser — refuse, named. + if (msg.kind === "open-html-external" && typeof (msg as { path?: unknown }).path === "string") { + const target = (msg as unknown as { path: string }).path; + const refuse = (why: string) => { + vscode.window.showWarningMessage(`Amicode: page not opened in the browser — ${why}`); + return true; + }; + if (!path.isAbsolute(target) || target.length > 4096) return refuse(`${target} is not an absolute path`); + if (!/\.html?$/i.test(target)) return refuse(`${path.basename(target)} is not an HTML file`); + if (vscode.env.remoteName) return refuse(`this is a remote window (${vscode.env.remoteName}); the page is on the remote machine`); + let isFile = false; + try { + isFile = fs.statSync(target).isFile(); + } catch {} + if (!isFile) return refuse(`${target} is not on this machine`); + void vscode.env.openExternal(vscode.Uri.file(target)); + return true; + } + // File bridge (chat links to vault notes, run artifacts): the framed app // renders LLM output and can't open local files — the extension opens them // for it. file:// URLs only; the resolved path must be absolute, bounded, diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 5e6927d0a..1e6fc10f5 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -716,7 +716,7 @@ export class ChatPanel implements LaunchTarget { vscode.postMessage(d); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "reveal-in-sidebar" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control" || d.kind === "whats-new-query" || d.kind === "whats-new-seen")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || d.kind === "open-html-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "reveal-in-sidebar" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control" || d.kind === "whats-new-query" || d.kind === "whats-new-seen")) { vscode.postMessage(d); } return; @@ -887,7 +887,7 @@ export class ChatPanel implements LaunchTarget { vscode.postMessage(d); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "reveal-in-sidebar" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control" || d.kind === "whats-new-query" || d.kind === "whats-new-seen")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || d.kind === "open-html-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "reveal-in-sidebar" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control" || d.kind === "whats-new-query" || d.kind === "whats-new-seen")) { vscode.postMessage(d); } return; diff --git a/packages/extension/src/deck/shell.ts b/packages/extension/src/deck/shell.ts index f3c3ff20f..1a6ad4807 100644 --- a/packages/extension/src/deck/shell.ts +++ b/packages/extension/src/deck/shell.ts @@ -487,6 +487,7 @@ window.addEventListener("message", (e) => { d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || + d.kind === "open-html-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index 31b3abe39..ae06b46a5 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -222,6 +222,65 @@ describe("amicode bridge — open-external", () => { }); }); +describe("amicode bridge — open-html-external pops a previewed page out to the browser (#2143)", () => { + const win = vscode.window as unknown as { messages: string[] }; + const venv = vscode.env as unknown as { remoteName: string | undefined }; + afterEach(() => { + venv.remoteName = undefined; + win.messages.length = 0; + }); + + it("opens an existing local .html/.htm file with the system handler", () => { + for (const ext of [".html", ".HTM"]) { + const target = path.join(os.tmpdir(), `amicode dashboard ${Date.now()}${ext}`); + fs.writeFileSync(target, "

status

"); + const before = env.opened.length; + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "open-html-external", path: target }, host())).toBe(true); + expect(env.opened).toHaveLength(before + 1); + expect((env.opened[env.opened.length - 1] as { fsPath: string }).fsPath).toBe(target); + fs.rmSync(target, { force: true }); + } + }); + + it("refuses relative paths, missing files and non-HTML files, naming why", () => { + const script = path.join(os.tmpdir(), `amicode not-html ${Date.now()}.command`); + fs.writeFileSync(script, "echo hi"); + for (const p of ["status.html", "/definitely/not/here-xyz.html", script]) { + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "open-html-external", path: p }, host())).toBe(true); + } + expect(env.opened).toHaveLength(0); + expect(win.messages).toHaveLength(3); + expect(win.messages.every((m) => m.startsWith("Amicode: page not opened in the browser"))).toBe(true); + fs.rmSync(script, { force: true }); + }); + + it("refuses in a remote window (the file is not on the machine showing the browser)", () => { + const target = path.join(os.tmpdir(), `amicode remote ${Date.now()}.html`); + fs.writeFileSync(target, "

x

"); + venv.remoteName = "ssh-remote"; + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "open-html-external", path: target }, host())).toBe(true); + expect(env.opened).toHaveLength(0); + expect(win.messages[0]).toMatch(/remote/); + fs.rmSync(target, { force: true }); + }); + + it("a malformed payload is not consumed", () => { + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "open-html-external", path: 7 }, host())).toBe(false); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "open-html-external" }, host())).toBe(false); + }); + + it("both webview shells forward the kind to the extension (chat panel twice, deck shell once)", () => { + const chat = fs.readFileSync(path.join(__dirname, "..", "src", "chat_panel.ts"), "utf8"); + const deck = fs.readFileSync(path.join(__dirname, "..", "src", "deck", "shell.ts"), "utf8"); + expect(chat.match(/d\.kind === "open-html-external"/g)).toHaveLength(2); + expect(deck.match(/d\.kind === "open-html-external"/g)).toHaveLength(1); + }); + + function host() { + return io(); + } +}); + describe("amicode bridge — open-file routes to preview-file (#935)", () => { it("routes markdown files to preview-file instead of markdown.showPreview", async () => { const host = io(); From 86ee8bb79ad260ce464d56dfcbe2c60d3514f1e0 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Fri, 6 Nov 2026 13:55:42 -0500 Subject: [PATCH 4/4] feat(research): the loop rewrites a live campaign dashboard page (#2144) The research skill gains the dashboard contract: one static HTML page per campaign, a derived view of the ledger rewritten at kickoff and every record step - status line, needs-you, loop table (verdicts copied, never upgraded), key findings, plots as sibling files - static only (Preview renders it with no scripts), linked once in chat as a file:// link. In a Research Project it lives under reports/dashboards/, otherwise under ~/.amico/dashboards/ (never the synced vault). The mode card (and its legacy mirror) points the record step at the page instead of the nonexistent dashboard widget. Part of #2140. --- packages/extension/agents/research.md | 3 +- packages/extension/modes/research/card.md | 3 +- packages/extension/skills/research/SKILL.md | 38 +++++++++++++++++++ .../test/workflow_skills_public.test.ts | 36 ++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/extension/agents/research.md b/packages/extension/agents/research.md index 755a0b2df..eefb31621 100644 --- a/packages/extension/agents/research.md +++ b/packages/extension/agents/research.md @@ -81,7 +81,8 @@ skill-friction check — instructions-vs-reality mismatches become findings, repeated un-skilled idioms become skill proposals — and the record boundary carries the same skill-delta row (findings filed, skills touched, proposals pending) PLUS the loop's visual record: pulse/data plot artifacts beside the -run outputs and the campaign dashboard widget refresh where one exists, and +run outputs and the campaign dashboard page rewrite (the `research` skill's +dashboard contract), and vault knowledge cards for measured laws. Recording, refining, and curating skills and vault knowledge is a first-class loop output — meta-improvement rides the loop, never trails it (the `record-boundary-curation` gate). diff --git a/packages/extension/modes/research/card.md b/packages/extension/modes/research/card.md index 755a0b2df..eefb31621 100644 --- a/packages/extension/modes/research/card.md +++ b/packages/extension/modes/research/card.md @@ -81,7 +81,8 @@ skill-friction check — instructions-vs-reality mismatches become findings, repeated un-skilled idioms become skill proposals — and the record boundary carries the same skill-delta row (findings filed, skills touched, proposals pending) PLUS the loop's visual record: pulse/data plot artifacts beside the -run outputs and the campaign dashboard widget refresh where one exists, and +run outputs and the campaign dashboard page rewrite (the `research` skill's +dashboard contract), and vault knowledge cards for measured laws. Recording, refining, and curating skills and vault knowledge is a first-class loop output — meta-improvement rides the loop, never trails it (the `record-boundary-curation` gate). diff --git a/packages/extension/skills/research/SKILL.md b/packages/extension/skills/research/SKILL.md index ff9dfb4a8..70dd78670 100644 --- a/packages/extension/skills/research/SKILL.md +++ b/packages/extension/skills/research/SKILL.md @@ -43,6 +43,7 @@ block in the system prompt (injected by `stack_state.ts` when a workspace folder | Data | `/data/` | | Config | `/config/` | | Reports | `/reports/` | +| Campaign dashboard | `/reports/dashboards//index.html` (see below) | | Checkout registry | `/ledger/campaigns/CHECKOUTS.md` | | Environment proposals | `/ledger/environment-proposals/` (created on demand) | | Environment digest | `/ledger/campaigns/env-digest-.md` | @@ -156,9 +157,46 @@ reference the in-flight casts? Append the audit row to §9. touched, proposals pending — so the loop's own improvement is part of its record. Rewrite the session todo list to mirror §3 and §5 — the derived-view rule in `director-core`: current loop only; a stale todo list is a lying §3. + Rewrite the campaign dashboard from the ledger you just committed (below). 8. **Repeat.** Compact only at a boundary, and only when the user is present to choose it — the protocol does not otherwise try to time compaction (see below). +## The campaign dashboard (live status page) + +One static HTML page per campaign that the researcher watches instead of the chat. It is +a **derived view of the ledger** — rewrite it whole at kickoff and at every record step +(and when a cast starts or blocks), never hand-edit it, never let it say more than the +ledger does. + +**Where:** inside a Research Project, `/reports/dashboards//index.html`; +otherwise `~/.amico/dashboards//index.html` (local, never the vault — plot +churn does not belong in a synced repo). `` is the ledger's file name without +`.md`. Plots go in `plots/` beside the page and are referenced relatively +(``). + +**Sections, top to bottom:** + +1. **Status line** — the objective in one line; phase, loop number, last-updated time + (ISO, with timezone), and one state word: running, waiting, blocked, or done. +2. **Needs you** — §4 blocked items, pending approvals and open human gates, each with + what is needed. When empty, say "Nothing — running unattended". +3. **Loop table** — one row per H#: spec, cast in flight, verdict copied from the ledger + (derived, never upgraded), and a link to the evidence. +4. **Key findings** — short bullets with the numbers in the researcher's notation; an + unverified result says UNTRUSTED. +5. **Plots** — the current loop's figures, newest first, each with a one-line caption. +6. Log — newest first, one line per event, trimmed to the last ~30. + +**Static only.** Preview renders the page in a sandbox with no scripts: no scripts, no CDN +libraries (Plotly, MathJax), no meta refresh, no external stylesheets. Use inline CSS, +tables, inline SVG, and PNG/SVG files in `plots/`. Keep the page under 1 MB. Preview reloads +it when the file changes, so a rewrite is the refresh. + +**Link it once.** After the first write, post the page as a `file://` link in chat +(`[campaign dashboard](file:///abs/path/index.html)`); one click opens it in Preview, where +it stays live, and its "Open in browser" control pops it out for a second monitor. Do not +re-post the link every loop. + ## Roles at a glance | Role | Subagent | Briefed with | Returns | Never | diff --git a/packages/extension/test/workflow_skills_public.test.ts b/packages/extension/test/workflow_skills_public.test.ts index 2ad79df7c..52826dc10 100644 --- a/packages/extension/test/workflow_skills_public.test.ts +++ b/packages/extension/test/workflow_skills_public.test.ts @@ -253,3 +253,39 @@ describe("content lens — the public workflow skills carry no proprietary or in } }); }); + +// ── #2144: the research loop writes a live campaign dashboard page ────────── + +describe("the research skill carries the live campaign dashboard contract (#2144)", () => { + const research = skillText("research"); + const card = readFileSync(join(MODES, "research", "card.md"), "utf8"); + + it("names where the page lives, inside a Research Project and outside one", () => { + expect(research).toMatch(/## The campaign dashboard/); + expect(research).toContain("/reports/dashboards//index.html"); + expect(research).toContain("~/.amico/dashboards//index.html"); + }); + + it("names the required sections", () => { + for (const section of ["Status line", "Needs you", "Loop table", "Key findings", "Plots"]) { + expect(research, `dashboard section ${section}`).toContain(`**${section}**`); + } + }); + + it("states the static-only rule Preview's sandbox enforces", () => { + expect(research).toMatch(/no scripts/i); + expect(research).toMatch(/no CDN/i); + expect(research).toMatch(/meta refresh/i); + expect(research).toMatch(/plots\//); + }); + + it("binds the rewrite to the record step and the one-time chat link", () => { + expect(research).toMatch(/Rewrite the campaign dashboard/); + expect(research).toMatch(/file:\/\//); + }); + + it("the mode card's record sentence points at the dashboard page, not a widget", () => { + expect(card).toContain("the campaign dashboard page rewrite"); + expect(card).not.toContain("campaign dashboard widget refresh"); + }); +});