Run it in TeamUp: live React preview of a task's artifact
Adds LivePreview — a sandboxed iframe that transpiles an agent's component with Babel and renders it live with React + Tailwind from CDN (no build step), so a generated page runs inside TeamUp. A "Preview" button on any task with an artifact opens a full-screen live view. Pairs with steering the engineer agent to output a single self-contained App component. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the most likely runnable code out of an artifact: the largest fenced code block if the agent
|
||||||
|
* wrapped its answer in markdown, otherwise the whole text.
|
||||||
|
*/
|
||||||
|
export function extractCode(artifact: string): string {
|
||||||
|
const blocks = [...artifact.matchAll(/```(?:tsx|jsx|ts|js|javascript|typescript)?\s*\n([\s\S]*?)```/g)].map((m) => m[1])
|
||||||
|
if (blocks.length === 0) return artifact.trim()
|
||||||
|
return blocks.sort((a, b) => b.length - a.length)[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a self-contained HTML document that transpiles the component (Babel) and renders it with
|
||||||
|
* React + Tailwind from CDN. The agent's code must define a component named `App`. */
|
||||||
|
function harness(code: string): string {
|
||||||
|
const json = JSON.stringify(code)
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
|
||||||
|
<script type="importmap">{"imports":{"react":"https://esm.sh/react@18.3.1","react-dom":"https://esm.sh/react-dom@18.3.1","react-dom/client":"https://esm.sh/react-dom@18.3.1/client","react/jsx-runtime":"https://esm.sh/react@18.3.1/jsx-runtime","react/jsx-dev-runtime":"https://esm.sh/react@18.3.1/jsx-dev-runtime"}}</script>
|
||||||
|
<style>body{margin:0;font-family:system-ui,sans-serif}.__err{padding:18px;font:12px/1.6 ui-monospace,monospace;color:#b91c1c;white-space:pre-wrap}</style>
|
||||||
|
</head><body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
var CODE = ${json};
|
||||||
|
function esc(s){return String(s).replace(/[<&]/g,function(c){return c==='<'?'<':'&'})}
|
||||||
|
function showErr(m){var r=document.getElementById('root');if(r)r.innerHTML='<div class="__err">Preview error:\\n'+esc(m)+'</div>'}
|
||||||
|
window.addEventListener('error',function(e){showErr(e.message||e)});
|
||||||
|
window.addEventListener('unhandledrejection',function(e){showErr((e.reason&&e.reason.message)||e.reason||'Error')});
|
||||||
|
try{
|
||||||
|
var t = Babel.transform(CODE,{presets:[['react',{runtime:'automatic'}],'typescript'],filename:'App.tsx'}).code;
|
||||||
|
var boot = t + "\\nimport React from 'react';\\nimport { createRoot } from 'react-dom/client';\\nvar __C = (typeof App!=='undefined') ? App : null;\\nif(!__C){ throw new Error('No component named App. Output a component, e.g. export default function App(){ return <div/> }'); }\\ncreateRoot(document.getElementById('root')).render(React.createElement(__C));";
|
||||||
|
var s=document.createElement('script');s.type='module';s.textContent=boot;document.body.appendChild(s);
|
||||||
|
}catch(e){showErr(e.message)}
|
||||||
|
</script>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs an agent's React component artifact live, in a sandboxed iframe. */
|
||||||
|
export function LivePreview({ artifact, className }: { artifact: string; className?: string }) {
|
||||||
|
const srcDoc = useMemo(() => harness(extractCode(artifact)), [artifact])
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
title="Live preview"
|
||||||
|
srcDoc={srcDoc}
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
className={className ?? 'h-[70vh] w-full rounded-lg border bg-white'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import { Bot, Plus, Trash2 } from 'lucide-react'
|
import { Bot, Play, Plus, Trash2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell'
|
import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell'
|
||||||
|
import { LivePreview } from '@/components/LivePreview'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -405,6 +406,7 @@ function TaskDrawer({
|
|||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [seatId, setSeatId] = useState<string>('')
|
const [seatId, setSeatId] = useState<string>('')
|
||||||
|
const [preview, setPreview] = useState(false)
|
||||||
const aiSeats = seats.filter((s) => s.state === 'Ai')
|
const aiSeats = seats.filter((s) => s.state === 'Ai')
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
@@ -564,10 +566,16 @@ function TaskDrawer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-2 border-t pt-4">
|
<div className="mt-2 flex items-center gap-2 border-t pt-4">
|
||||||
|
{task.description && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPreview(true)}>
|
||||||
|
<Play data-icon="inline-start" /> Preview
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="ml-auto"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`Delete "${task.title}"? This can't be undone.`)) {
|
if (window.confirm(`Delete "${task.title}"? This can't be undone.`)) {
|
||||||
@@ -579,6 +587,19 @@ function TaskDrawer({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
||||||
|
{preview && task.description && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex flex-col gap-2 bg-background/95 p-4 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Live preview · {task.title}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPreview(false)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
<LivePreview artifact={task.description} className="w-full flex-1 rounded-lg border bg-white" />
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Runs the artifact as a React component (Babel + Tailwind, no build). Define a component named App.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user