From 9453e0d550246f15e9a719b796ecad3ce5d278ab Mon Sep 17 00:00:00 2001 From: gagan Date: Thu, 11 Jun 2026 01:45:10 +0530 Subject: [PATCH] feat: render background-task HTML output and open its links externally (#615) * feat: render background-task index.html output in a sandboxed iframe The task output pane now prefers bg-tasks//index.html when present and non-empty, rendering it full-bleed via HtmlFileViewer (app://workspace protocol) so CSS, layout, and scripts render faithfully. Falls back to the markdown index.md note when there is no HTML artifact. The viewer remounts on refreshKey so a re-run's updated HTML reloads. The Source/Rendered toggle works for both formats. The runner agent is instructed to choose index.md (default, notes) vs a self-contained index.html (visual/styled output) per run, written via the existing file-writeText tool. The Copilot background-task skill notes the HTML option so visual asks are steered toward it. * fix: open links from HTML report iframes in the system browser Links inside the sandboxed iframe that renders a background-task/workspace index.html did nothing on click, unlike the markdown viewer which opens links in the browser. Two causes: target="_blank" links were blocked by the sandbox before reaching the window-open handler, and plain links fire will-frame-navigate (subframe), which the app did not handle (will-navigate only covers the main frame). - Add allow-popups to the HtmlFileViewer iframe sandbox so target="_blank" reaches setWindowOpenHandler, which routes to shell.openExternal. - Handle will-frame-navigate in main, routing external subframe navigations to the system browser. Scoped to app://workspace frames so third-party note embeds (YouTube/Figma/Twitter) keep their internal navigation. --- apps/x/apps/main/src/main.ts | 32 +++++-- .../renderer/src/components/bg-tasks-view.tsx | 84 ++++++++++++------- .../src/components/html-file-viewer.tsx | 7 +- .../assistant/skills/background-task/skill.ts | 4 +- .../core/src/background-tasks/agent.ts | 9 +- 5 files changed, 98 insertions(+), 38 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index f4415b5d..40e49e35 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -253,14 +253,34 @@ function createWindow() { return { action: "deny" }; }); - // Handle navigation to external URLs (e.g., clicking a link without target="_blank") - win.webContents.on("will-navigate", (event, url) => { + // Handle navigation to external URLs (e.g., clicking a link without target="_blank"). + // Returns true when the URL was external and routed to the system browser. + const routeExternalNavigation = (url: string): boolean => { const isInternal = url.startsWith("app://") || url.startsWith("http://localhost:5173"); - if (!isInternal) { - event.preventDefault(); - shell.openExternal(url); - } + if (isInternal) return false; + shell.openExternal(url); + return true; + }; + + win.webContents.on("will-navigate", (event, url) => { + if (routeExternalNavigation(url)) event.preventDefault(); + }); + + // Subframe navigations (e.g. links clicked inside the sandboxed iframe that + // renders a background-task / workspace `index.html`) fire `will-frame-navigate`, + // not `will-navigate`. Route their external links to the system browser too, + // so HTML reports behave like the markdown viewer. Main-frame navigations are + // already handled by `will-navigate` above — skip them here to avoid double-open. + // + // Scope this to our own HTML viewer frames (identified by their app://workspace + // document origin). Third-party note embeds (YouTube, Figma, Twitter via the + // embed/iframe blocks) load from their own origins — leave their internal + // navigation untouched so the embeds keep working. + win.webContents.on("will-frame-navigate", (event) => { + if (event.isMainFrame) return; + if (!event.frame?.url.startsWith("app://workspace/")) return; + if (routeExternalNavigation(event.url)) event.preventDefault(); }); // Attach the embedded browser pane manager to this window. diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index 4ba7479f..0641bc13 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation' import { runLogToConversation } from '@/lib/run-to-conversation' import { CompactConversation } from '@/components/compact-conversation' import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' +import { HtmlFileViewer } from '@/components/html-file-viewer' // --------------------------------------------------------------------------- // Trigger helpers (inlined; extract to shared as a follow-up) @@ -502,15 +503,22 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re } // --------------------------------------------------------------------------- -// Output pane — index.md (main pane content) +// Output pane — index.html (preferred) or index.md (main pane content) // -// Renders the task's `index.md` like a note: max-width 720px centered, same -// typography (~16px, 1.5 line-height, generous padding) as the note editor's -// ProseMirror rule in `editor.css`. No chrome above the body — just the -// markdown, with a small floating Source ⇄ Rendered toggle in the top-right. +// A task's agent-owned artifact is either: +// - `index.html` — a self-contained, styled web page. Rendered full-bleed in +// a sandboxed iframe (via `HtmlFileViewer` / the `app://workspace` +// protocol) so CSS, layout, and scripts render faithfully. Preferred when +// present and non-empty. +// - `index.md` — a note. Rendered like the note editor: max-width 720px +// centered, same typography as `editor.css`, via `RichMarkdownViewer`. +// +// In both cases a small floating Source ⇄ Rendered toggle in the top-right +// swaps the rendered view for the raw file source. // --------------------------------------------------------------------------- function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: string; refreshKey: number }) { + const [mode, setMode] = useState<'md' | 'html'>('md') const [body, setBody] = useState('') const [loading, setLoading] = useState(true) const [viewSource, setViewSource] = useState(false) @@ -519,21 +527,33 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st let cancelled = false setLoading(true) void (async () => { + // Prefer index.html when it exists and has content; otherwise fall + // back to index.md (the default seeded artifact). try { - const result = await window.ipc.invoke('workspace:readFile', { + const html = await window.ipc.invoke('workspace:readFile', { + path: `bg-tasks/${slug}/index.html`, + }) + if (html.data.trim()) { + if (!cancelled) { setMode('html'); setBody(html.data) } + return + } + } catch { + // No index.html — fall through to markdown. + } + try { + const md = await window.ipc.invoke('workspace:readFile', { path: `bg-tasks/${slug}/index.md`, }) - if (!cancelled) setBody(result.data) + if (!cancelled) { setMode('md'); setBody(md.data) } } catch { - if (!cancelled) setBody('') - } finally { - if (!cancelled) setLoading(false) + if (!cancelled) { setMode('md'); setBody('') } } - })() + })().finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [slug, refreshKey]) - const isEmpty = !body.trim() || body.trim() === `# ${taskName}` + const isEmpty = mode === 'md' && (!body.trim() || body.trim() === `# ${taskName}`) + const showHtml = mode === 'html' && !viewSource return (
@@ -542,29 +562,35 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st type="button" onClick={() => setViewSource(v => !v)} className="absolute right-4 top-3 z-10 rounded-md bg-background/70 px-2 py-0.5 text-[11px] text-muted-foreground backdrop-blur hover:bg-accent hover:text-foreground" - aria-label={viewSource ? 'Show rendered output' : 'Show source markdown'} + aria-label={viewSource ? 'Show rendered output' : 'Show source'} > {viewSource ? 'Rendered' : 'Source'} )} -
-
- {loading ? ( -
- Loading… -
- ) : isEmpty ? ( -

- No output yet. Click Run now in the sidebar, or wait for a trigger to fire. -

- ) : viewSource ? ( -
{body}
- ) : ( - - )} + {showHtml ? ( + // Full-bleed: the iframe fills the pane and scrolls internally. + // Remount on refreshKey so a re-run's updated index.html reloads. + + ) : ( +
+
+ {loading ? ( +
+ Loading… +
+ ) : isEmpty ? ( +

+ No output yet. Click Run now in the sidebar, or wait for a trigger to fire. +

+ ) : viewSource ? ( +
{body}
+ ) : ( + + )} +
-
+ )}
) } diff --git a/apps/x/apps/renderer/src/components/html-file-viewer.tsx b/apps/x/apps/renderer/src/components/html-file-viewer.tsx index 8343af28..79cccbc7 100644 --- a/apps/x/apps/renderer/src/components/html-file-viewer.tsx +++ b/apps/x/apps/renderer/src/components/html-file-viewer.tsx @@ -110,7 +110,12 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {