From 344d30c9511a63f6ff9d570a521c7bd51bb09da0 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Thu, 11 Jun 2026 01:34:43 +0530 Subject: [PATCH] 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. --- .../renderer/src/components/bg-tasks-view.tsx | 84 ++++++++++++------- .../assistant/skills/background-task/skill.ts | 4 +- .../core/src/background-tasks/agent.ts | 9 +- 3 files changed, 66 insertions(+), 31 deletions(-) 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/packages/core/src/application/assistant/skills/background-task/skill.ts b/apps/x/packages/core/src/application/assistant/skills/background-task/skill.ts index 7ebbd985..cc6a5b19 100644 --- a/apps/x/packages/core/src/application/assistant/skills/background-task/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/background-task/skill.ts @@ -10,7 +10,9 @@ export const skill = String.raw` A *background task* is a persistent agent the user configures once and the framework keeps firing — on a schedule, inside time-of-day windows, and/or in response to matching incoming events (Gmail threads, calendar changes). Each task lives at \`bg-tasks//\` and owns two artifacts: - \`task.yaml\` — the spec (the user's **instructions**, triggers, runtime state). You and the user both treat this as the source of truth. -- \`index.md\` — the agent-owned body. The runtime never writes here; the bg-task agent does, each run. +- \`index.md\` — the agent-owned body (a note). The runtime never writes here; the bg-task agent does, each run. + +For **visual** output — a dashboard, a styled report, a metrics table with conditional colors, a chart — the agent may instead write a self-contained \`index.html\`, which the task view renders full-screen in a sandboxed iframe with CSS and layout preserved. The agent picks the format per run from the instructions; you don't set it, but when the ask is inherently visual, say so in the instructions (e.g. "…rendered as a styled HTML dashboard") so the agent leans that way. A task is one of two shapes — the agent decides per run from the verbs in \`instructions\`: diff --git a/apps/x/packages/core/src/background-tasks/agent.ts b/apps/x/packages/core/src/background-tasks/agent.ts index 853c1ef0..95c1df54 100644 --- a/apps/x/packages/core/src/background-tasks/agent.ts +++ b/apps/x/packages/core/src/background-tasks/agent.ts @@ -15,7 +15,8 @@ You are running with **no user present** to clarify, approve, or watch. Your task folder is \`bg-tasks//\` (the path is given in the run message). It contains: - \`task.yaml\` — the spec. **Never touch this.** The runtime owns it. -- \`index.md\` — agent-owned. You read and write this freely via \`file-readText\` / \`file-editText\`. +- \`index.md\` — the default agent-owned artifact (a note). You read and write it freely via \`file-readText\` / \`file-editText\`. +- \`index.html\` — optional agent-owned artifact for **visual** output (see OUTPUT MODE). When it exists and is non-empty it is shown to the user instead of \`index.md\`. - \`runs/\` — your own run logs (jsonl). You don't write to it directly; the runtime does. You can also read and write anywhere else under the workspace (\`knowledge/\`, etc.) when your instructions call for it. @@ -28,6 +29,12 @@ Use when instructions imply a **current state** artifact: - "Keep me posted on …" / "What's the latest on …" On every run: \`file-readText\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`file-editText\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites. +Pick the artifact format from what the output needs: +- **\`index.md\`** (default) — prose, lists, summaries, digests, briefs. Rendered as a styled note. Use patch-style edits as above. +- **\`index.html\`** — when the output is inherently **visual**: a dashboard, a metrics table with conditional colors, a chart, a styled report — anything where layout/CSS carry meaning that a plain note would lose. Write a single **self-contained** file with \`file-writeText\` (inline all CSS and JS; avoid external/CDN dependencies as they may be blocked; reference only assets you save next to it in the task folder — relative paths resolve against the folder). It renders full-screen in a sandboxed iframe. HTML is typically regenerated wholesale each run, so a one-shot \`file-writeText\` is fine here. + +Use ONE format per task — don't maintain both. \`index.html\` wins when present and non-empty. If you move a task from HTML back to a plain note, blank out \`index.html\` (\`file-writeText\` with \`""\`) so \`index.md\` shows again. + ACTION MODE — perform a side-effect, append a journal entry. Use when instructions imply a **recurring action**: - "Send / draft / post / notify / file / reply / publish / call / forward …"