mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
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/<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. * 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.
This commit is contained in:
parent
c48ef5ac0c
commit
9453e0d550
5 changed files with 98 additions and 38 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,12 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
|||
<iframe
|
||||
key={path}
|
||||
src={iframeSrc}
|
||||
sandbox="allow-scripts"
|
||||
// `allow-popups` lets `target="_blank"` links reach the main process
|
||||
// window-open handler, which routes them to the system browser. Plain
|
||||
// links (same-frame navigations) are handled there via
|
||||
// `will-frame-navigate`. No `allow-same-origin` — the doc stays
|
||||
// origin-isolated.
|
||||
sandbox="allow-scripts allow-popups"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
|
|
|
|||
|
|
@ -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\`:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 …"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue