Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OMA Forge</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
Expand Down
71 changes: 8 additions & 63 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,17 @@
import { useEffect, useState } from 'react'
import { TeamRunDashboard } from './components/dashboard/TeamRunDashboard.tsx'
import { demoTeamRun } from './data/demo-team-run.ts'

type HealthResponse = {
ok: boolean
runtime: string
orchestrator: unknown
}

export default function App() {
const [health, setHealth] = useState<HealthResponse | null>(null)
const [healthError, setHealthError] = useState<string | null>(null)

useEffect(() => {
fetch('/api/health')
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<HealthResponse>
})
.then(setHealth)
.catch((err: unknown) => {
setHealthError(err instanceof Error ? err.message : 'Failed to reach OMA Core')
})
}, [])
export default function App() {


return (
<div className="min-h-screen bg-zinc-950 text-zinc-100">
<header className="border-b border-zinc-800 px-6 py-4">
<p className="text-xs font-medium uppercase tracking-wider text-zinc-500">
Open Multi Agent
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight">OMA Forge</h1>
<p className="mt-1 max-w-2xl text-sm text-zinc-400">
Local development environment for building, running, and debugging OMA workflows.
</p>
<div className="relative h-screen overflow-hidden">
<header className="absolute top-0 right-0 z-50 flex items-center gap-3 px-6 py-3 text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">
<span className="text-primary font-black">OMA Forge</span>
</header>

<main className="mx-auto max-w-3xl px-6 py-10">
<section className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-6">
<h2 className="text-sm font-medium text-zinc-300">Scaffold</h2>
<p className="mt-2 text-sm leading-relaxed text-zinc-400">
This repo is an early v0.1 scaffold. Workflow loading, live DAG visualization, and
trace inspection will land in follow-up changes.
</p>

<div className="mt-4 rounded-md border border-zinc-800 bg-zinc-950/80 px-3 py-2 text-sm">
<span className="text-zinc-500">OMA Core: </span>
{health ? (
<span className="text-emerald-400">connected ({health.runtime})</span>
) : healthError ? (
<span className="text-amber-400">{healthError}</span>
) : (
<span className="text-zinc-500">checking…</span>
)}
</div>

<p className="mt-4 text-sm text-zinc-500">
Runtime:{' '}
<a
className="text-sky-400 underline-offset-2 hover:underline"
href="https://github.com/open-multi-agent/open-multi-agent"
rel="noreferrer"
target="_blank"
>
open-multi-agent
</a>{' '}
(<code className="text-zinc-400">@open-multi-agent/core</code>)
</p>
</section>
</main>
<TeamRunDashboard result={demoTeamRun} />
</div>
)
}
56 changes: 56 additions & 0 deletions apps/web/src/components/dashboard/DagEdges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { LayoutTasksResult } from '../../lib/layout-tasks.ts'
import type { TaskExecutionRecord } from '../../types/team-run.ts'

function makeEdgePath(x1: number, y1: number, x2: number, y2: number): string {
return `M ${x1} ${y1} C ${x1 + 42} ${y1}, ${x2 - 42} ${y2}, ${x2} ${y2}`
}

type DagEdgesProps = {
readonly tasks: readonly TaskExecutionRecord[]
readonly layout: LayoutTasksResult
}

export function DagEdges({ tasks, layout }: DagEdgesProps) {
const { positions, width, height, nodeW, nodeH } = layout

return (
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
viewBox={`0 0 ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<marker
id="arrow"
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
orient="auto"
>
<path d="M0,0 L8,4 L0,8 z" fill="#40485d" />
</marker>
</defs>
{tasks.flatMap((task) => {
const to = positions.get(task.id)
if (!to) return []

return (task.dependsOn ?? []).flatMap((depId) => {
const from = positions.get(depId)
if (!from) return []

return (
<path
key={`${depId}-${task.id}`}
d={makeEdgePath(from.x + nodeW, from.y + nodeH / 2, to.x, to.y + nodeH / 2)}
fill="none"
stroke="#40485d"
strokeWidth={2}
markerEnd="url(#arrow)"
/>
)
})
})}
</svg>
)
}
46 changes: 46 additions & 0 deletions apps/web/src/components/dashboard/DagNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { TaskExecutionRecord } from '../../types/team-run.ts'
import { durationText, statusStyles } from './status-styles.ts'

type DagNodeProps = {
readonly task: TaskExecutionRecord
readonly index: number
readonly x: number
readonly y: number
readonly onSelect: (task: TaskExecutionRecord) => void
}

export function DagNode({ task, index, x, y, onSelect }: DagNodeProps) {
const status = statusStyles[task.status] ?? statusStyles.pending
const nodeId = `#NODE_${String(index + 1).padStart(3, '0')}`
const chips = [task.assignee ? task.assignee.toUpperCase() : 'UNASSIGNED', status.chip]

return (
<button
type="button"
className={`node absolute w-64 border-l-2 p-4 cursor-pointer text-left ${status.border} ${status.container}`}
style={{ left: x, top: y }}
onClick={() => onSelect(task)}
>
<div className="flex justify-between items-start mb-4">
<span className={`text-[10px] font-mono ${status.iconColor}`}>{nodeId}</span>
<span
className={`material-symbols-outlined ${status.iconColor} text-lg ${status.spin ? 'animate-spin' : ''}`}
>
{status.icon}
</span>
</div>
<h3 className="font-headline font-bold text-sm tracking-tight mb-1">{task.title}</h3>
<p className={`text-xs ${status.statusColor} mb-4`}>STATUS: {durationText(task)}</p>
<div className="flex gap-2">
{chips.map((chip) => (
<span
key={chip}
className="px-2 py-0.5 bg-surface-variant text-[9px] font-mono text-on-surface-variant"
>
{chip}
</span>
))}
</div>
</button>
)
}
132 changes: 132 additions & 0 deletions apps/web/src/components/dashboard/DagViewport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'

type ViewTransform = {
readonly scale: number
readonly x: number
readonly y: number
}

const MIN_SCALE = 0.4
const MAX_SCALE = 2.5
/** Higher = faster zoom per scroll tick. */
const ZOOM_INTENSITY = 0.0035

type DagViewportProps = {
readonly width: number
readonly height: number
readonly children: ReactNode
}

function clampScale(scale: number): number {
return Math.min(Math.max(MIN_SCALE, scale), MAX_SCALE)
}

/** Zoom toward cursor for `translate(tx, ty) scale(s)` with origin top-left. */
function zoomTowardPoint(
prev: ViewTransform,
pointerX: number,
pointerY: number,
nextScale: number,
): ViewTransform {
const ratio = nextScale / prev.scale
return {
scale: nextScale,
x: pointerX - (pointerX - prev.x) * ratio,
y: pointerY - (pointerY - prev.y) * ratio,
}
}

function wheelDeltaPixels(event: WheelEvent): number {
let delta = event.deltaY
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
delta *= 16
} else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
delta *= event.currentTarget instanceof Element
? (event.currentTarget as Element).clientHeight
: 800
}
return delta
}

export function DagViewport({ width, height, children }: DagViewportProps) {
const viewportRef = useRef<HTMLDivElement>(null)
const transformRef = useRef<ViewTransform>({ scale: 1, x: 0, y: 0 })
const [transform, setTransform] = useState<ViewTransform>(transformRef.current)
const dragging = useRef(false)
const last = useRef({ x: 0, y: 0 })

const applyTransform = (next: ViewTransform) => {
transformRef.current = next
setTransform(next)
}

useEffect(() => {
const viewport = viewportRef.current
if (!viewport) return

const onWheel = (event: WheelEvent) => {
event.preventDefault()

const rect = viewport.getBoundingClientRect()
const pointerX = event.clientX - rect.left
const pointerY = event.clientY - rect.top
const delta = wheelDeltaPixels(event)
const prev = transformRef.current
const factor = Math.exp(-delta * ZOOM_INTENSITY)
const nextScale = clampScale(prev.scale * factor)

if (nextScale === prev.scale) return

applyTransform(zoomTowardPoint(prev, pointerX, pointerY, nextScale))
}

viewport.addEventListener('wheel', onWheel, { passive: false })
return () => viewport.removeEventListener('wheel', onWheel)
}, [])

const onMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
dragging.current = true
last.current = { x: e.clientX, y: e.clientY }
viewportRef.current?.classList.add('cursor-grabbing')
}

const onMouseMove = (e: React.MouseEvent) => {
if (!dragging.current) return
const dx = e.clientX - last.current.x
const dy = e.clientY - last.current.y
last.current = { x: e.clientX, y: e.clientY }
applyTransform({
...transformRef.current,
x: transformRef.current.x + dx,
y: transformRef.current.y + dy,
})
}

const onMouseUp = () => {
dragging.current = false
viewportRef.current?.classList.remove('cursor-grabbing')
}

return (
<div
ref={viewportRef}
className="flex-1 min-h-0 relative overflow-hidden cursor-grab touch-none"
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
>
<div
className="absolute top-0 left-0 origin-top-left will-change-transform"
style={{
width,
height,
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
}}
>
{children}
</div>
</div>
)
}
Loading
Loading