feat: render background-task index.html output in a sandboxed iframe

The task output pane now prefers bg-tasks/<slug>/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.
This commit is contained in:
Gagancreates 2026-06-11 01:34:43 +05:30
parent c48ef5ac0c
commit 344d30c951
3 changed files with 66 additions and 31 deletions

View file

@ -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 <TriggersEditor> 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<string>('')
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 (
<div className="relative flex-1 overflow-hidden bg-background">
@ -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'}
</button>
)}
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-[720px] px-16 py-8">
{loading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Loading
</div>
) : isEmpty ? (
<p className="text-sm italic text-muted-foreground">
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
</p>
) : viewSource ? (
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
) : (
<RichMarkdownViewer content={body} />
)}
{showHtml ? (
// Full-bleed: the iframe fills the pane and scrolls internally.
// Remount on refreshKey so a re-run's updated index.html reloads.
<HtmlFileViewer key={`${slug}-${refreshKey}`} path={`bg-tasks/${slug}/index.html`} />
) : (
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-[720px] px-16 py-8">
{loading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Loading
</div>
) : isEmpty ? (
<p className="text-sm italic text-muted-foreground">
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
</p>
) : viewSource ? (
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
) : (
<RichMarkdownViewer content={body} />
)}
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -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/<slug>/\` 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\`:

View file

@ -15,7 +15,8 @@ You are running with **no user present** to clarify, approve, or watch.
Your task folder is \`bg-tasks/<slug>/\` (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 …"