feat(web): redesign Overview as the homepage with a living graph backdrop
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Overview replaces Tasks as the default route: a centered new-task entry
with live fleet metrics, a scrollable/filterable task table, and an
ambient canvas rendering of the real entity graph (autonomous camera
drift + mouse parallax) behind it. Tasks sidebar entry is removed;
its status-bucketing logic moves to lib/tasks.ts for reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 11:07:36 +02:00
parent 0ed171507f
commit 6807e353e3
5 changed files with 510 additions and 442 deletions

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import Chat from './pages/Chat.svelte'
import Tasks from './pages/Tasks.svelte'
import Overview from './pages/Overview.svelte'
import Entities from './pages/Entities.svelte'
import Ops from './pages/Ops.svelte'
@@ -20,8 +19,6 @@
import { Separator } from '$lib/components/ui/separator'
import { Toaster } from '$lib/components/ui/sonner'
import PlusIcon from '@lucide/svelte/icons/plus'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
@@ -31,7 +28,7 @@
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
let page = $state('tasks')
let page = $state('overview')
let routeParam = $state('')
let drawerOpen = $state(false)
@@ -40,9 +37,9 @@
onMount(() => {
function sync() {
const path = location.hash.slice(2) || 'tasks'
const path = location.hash.slice(2) || 'overview'
const [head, ...rest] = path.split('/')
page = head || 'tasks'
page = head || 'overview'
routeParam = rest.join('/')
}
sync()
@@ -114,20 +111,10 @@
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={page === 'tasks' || page === 'chat'} onclick={() => navigate('tasks')} tooltipContent="Tasks">
{#snippet child({ props })}
<button {...props}>
<ListTodoIcon />
<span>Tasks</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#each navItems as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={page === item.id}
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
onclick={() => navigate(item.id)}
tooltipContent={item.label}
>
@@ -166,7 +153,7 @@
<Sidebar.Trigger class="-ms-1" />
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
{#if page === 'chat'}
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('tasks')}>Tasks</button>
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Overview</button>
<span class="text-muted-foreground">/</span>
<span class="text-base font-medium">Conversation</span>
{:else}
@@ -199,8 +186,6 @@
<main class="min-h-0 flex-1 overflow-hidden">
{#if page === 'overview'}
<Overview />
{:else if page === 'tasks'}
<Tasks />
{:else if page === 'entities'}
<Entities />
{:else if page === 'graph'}

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import { onMount } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
import { fetchGraph, type Health } from '$lib/api'
import { mode } from 'mode-watcher'
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
// host places this behind the page with pointer-events:none, so it never
// steals clicks. The "alive" feeling comes entirely from the camera (slow
// autonomous drift + mouse parallax + per-node depth), NOT from a live force
// sim — we warm the layout up once, freeze it, then just pan a static field.
interface SimNode {
id: string
slug: string
degree: number
z: number // depth in [0,1] for parallax
x?: number
y?: number
fx?: number | null
fy?: number | null
}
interface SimLink {
source: string | SimNode
target: string | SimNode
}
let host = $state<HTMLDivElement | null>(null)
let canvas = $state<HTMLCanvasElement | null>(null)
let nodes: SimNode[] = []
let links: SimLink[] = []
let health: Record<string, Health> = {}
// World bounds the layout is centered in; camera pans within.
const WORLD = 1400
const MAX_NODES = 260
const healthColor: Record<Health, string> = {
healthy: '#3fb950',
degraded: '#d29922',
down: '#f85149',
unknown: '#8b949e'
}
function nodeRadius(n: SimNode): number {
return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
}
async function loadGraph() {
const graph = await fetchGraph({ depth: 3, includeStatus: true })
if (!graph) return
health = graph.health ?? {}
// degree by id, edges reference slugs
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
const degree = new Map<string, number>()
for (const e of graph.edges) {
const s = idBySlug.get(e.source) ?? e.source
const t = idBySlug.get(e.target) ?? e.target
degree.set(s, (degree.get(s) ?? 0) + 1)
degree.set(t, (degree.get(t) ?? 0) + 1)
}
let all: SimNode[] = graph.nodes.map((n) => ({
id: n.id,
slug: n.slug,
degree: degree.get(n.id) ?? 0,
z: Math.random()
}))
// Cap to the most-connected nodes so large graphs stay cheap.
if (all.length > MAX_NODES) {
all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
}
const keep = new Set(all.map((n) => n.id))
nodes = all
links = graph.edges
.map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
.filter((l) => keep.has(l.source as string) && keep.has(l.target as string))
warmUpLayout()
}
// Run the sim to a settled state without rendering each tick, then freeze.
function warmUpLayout() {
const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
.force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
.force('charge', forceManyBody().strength(-140).distanceMax(360))
.force('center', forceCenter(0, 0))
.force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
.stop()
const ticks = Math.min(400, Math.max(120, nodes.length * 2))
for (let i = 0; i < ticks; i++) sim.tick()
sim.stop()
}
// ─── camera + render loop ───────────────────────────────────────────────
let cam = { x: 0, y: 0 } // eased mouse-parallax offset
let targetCam = { x: 0, y: 0 }
let timer: ReturnType<typeof setTimeout> | 0 = 0
let dpr = 1
let w = 0
let h = 0
function onPointerMove(e: PointerEvent) {
if (!host) return
const rect = host.getBoundingClientRect()
const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
const ny = (e.clientY - rect.top) / rect.height - 0.5
targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
}
function resize() {
if (!host || !canvas) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
w = host.clientWidth
h = host.clientHeight
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
}
function colorForNode(n: SimNode): string {
return healthColor[health[n.id] ?? 'unknown']
}
// Driven by setTimeout rather than requestAnimationFrame: some embedding
// contexts (iframed previews, backgrounded-but-visible panes) report
// document.hidden = true and browsers fully suspend rAF callbacks there,
// which would freeze this canvas forever. setTimeout keeps ticking
// regardless, and ~30fps is plenty for a slow ambient drift.
function draw(t: number) {
timer = setTimeout(() => draw(performance.now()), 33)
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// ease parallax toward target
cam.x += (targetCam.x - cam.x) * 0.05
cam.y += (targetCam.y - cam.y) * 0.05
// autonomous drift (Lissajous pan + breathing zoom)
const ts = t / 1000
const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
const dark = mode.current !== 'light'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const cx = w / 2
const cy = h / 2
// project a world point to screen, applying per-depth parallax
function project(px: number, py: number, z: number) {
const par = 0.5 + z // nearer nodes (higher z) move more
const ox = (driftX + cam.x) * par
const oy = (driftY + cam.y) * par
return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
}
// edges
ctx.lineWidth = 1
ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
ctx.beginPath()
for (const l of links) {
const s = l.source as SimNode
const tg = l.target as SimNode
if (s.x == null || tg.x == null) continue
const z = (s.z + tg.z) / 2
const a = project(s.x, s.y!, z)
const b = project(tg.x, tg.y!, z)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
// nodes (glow via radial gradient, cheap enough at this count)
for (const n of nodes) {
if (n.x == null || n.y == null) continue
const p = project(n.x, n.y, n.z)
const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
const col = colorForNode(n)
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
glow.addColorStop(1, hexA(col, 0))
ctx.fillStyle = glow
ctx.beginPath()
ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
ctx.beginPath()
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
ctx.fill()
}
// legibility scrim: dim only the center band where the UI sits, taper to
// ~nothing at the edges so the graph (and its connections) stay visible
// in the margins instead of being crushed everywhere equally.
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
const base = dark ? '13,17,23' : '255,255,255'
scrim.addColorStop(0, `rgba(${base},0.68)`)
scrim.addColorStop(0.45, `rgba(${base},0.32)`)
scrim.addColorStop(1, `rgba(${base},0.02)`)
ctx.fillStyle = scrim
ctx.fillRect(0, 0, w, h)
}
// "#rrggbb" + alpha -> rgba()
function hexA(hex: string, a: number): string {
const n = parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
}
onMount(() => {
loadGraph()
resize()
const ro = new ResizeObserver(resize)
if (host) ro.observe(host)
window.addEventListener('pointermove', onPointerMove)
timer = setTimeout(() => draw(performance.now()), 33)
return () => {
clearTimeout(timer)
ro.disconnect()
window.removeEventListener('pointermove', onPointerMove)
}
})
</script>
<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
<canvas bind:this={canvas} class="h-full w-full"></canvas>
</div>

53
web/src/lib/tasks.ts Normal file
View File

@@ -0,0 +1,53 @@
// Shared task-status helpers. Used by the Overview homepage (and previously the
// standalone Tasks board) to map a session's lifecycle onto a small set of
// display buckets, styles, and filters.
import type { Session } from '$lib/api'
export type Bucket = 'running' | 'input' | 'done' | 'failed'
export function bucket(s: Session): Bucket {
switch (s.status) {
case 'awaiting_input':
return 'input'
case 'done':
return s.outcome === 'failure' ? 'failed' : 'done'
case 'failed':
return 'failed'
default:
return 'running' // active | planning | executing | undefined
}
}
export interface StatusStyle {
label: string
dot: string
pulse: boolean
}
export function statusStyle(s: Session): StatusStyle {
switch (bucket(s)) {
case 'input':
return { label: 'Needs input', dot: 'bg-warning', pulse: true }
case 'done':
return { label: s.outcome === 'partial' ? 'Done · partial' : 'Done', dot: 'bg-success', pulse: false }
case 'failed':
return { label: 'Failed', dot: 'bg-destructive', pulse: false }
default:
return { label: 'Running', dot: 'bg-primary', pulse: true }
}
}
export const FILTERS: { id: 'all' | Bucket; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'running', label: 'Running' },
{ id: 'input', label: 'Needs input' },
{ id: 'done', label: 'Done' },
{ id: 'failed', label: 'Failed' }
]
// Task events that should trigger a session-board refetch.
export const TASK_EVENTS = new Set(['task.status', 'goal.set', 'question.raised', 'question.answered'])
export function heading(s: Session): string {
return s.goal || s.title || 'Untitled task'
}

View File

@@ -1,64 +1,28 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
import { sessions, loadSessions, loadSessionMessages, newChat, sendMessage } from '$lib/stores/chat'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import * as Card from '$lib/components/ui/card'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { relativeTime } from '$lib/utils'
import GraphBackground from '$lib/components/GraphBackground.svelte'
import { Textarea } from '$lib/components/ui/textarea'
import { Button } from '$lib/components/ui/button'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
import OctagonXIcon from '@lucide/svelte/icons/octagon-x'
// ── Dashboard metrics (ported from the old Overview cards) ──────────────
let summary = $state<DashboardSummary | null>(null)
let loading = $state(true)
async function load() {
async function loadSummary() {
summary = await fetchDashboardSummary()
loading = false
}
onMount(() => {
load()
const unsubscribe = subscribeEvents()
const interval = setInterval(load, 15000)
return () => {
unsubscribe()
clearInterval(interval)
}
})
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
const degradedTypes = $derived(
summary
? [
{ label: 'Degraded', count: summary.health.degraded, tone: 'text-warning' as const },
{ label: 'Down', count: summary.health.down, tone: 'text-destructive' as const }
].filter((t) => t.count > 0)
: []
)
const maxEventRate = $derived(
summary?.event_rate.length ? Math.max(...summary.event_rate.map((b) => b.count), 1) : 1
)
const totalEntities = $derived(
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
)
const topTypes = $derived(
summary
? Object.entries(summary.entities_by_type)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
: []
)
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
const totalMonitored = $derived(
summary
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
@@ -67,7 +31,6 @@
const healthTone = $derived(
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
)
const totalSignals = $derived(
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
)
@@ -79,174 +42,221 @@
: 'none'
)
const executionsRunning = $derived(summary?.executions_by_state.running ?? 0)
const executionsFailed = $derived(summary?.executions_by_state.failed ?? 0)
// ── Task board ──────────────────────────────────────────────────────────
let filter = $state<'all' | Bucket>('all')
const counts = $derived.by(() => {
const c: Record<string, number> = { all: $sessions.length, running: 0, input: 0, done: 0, failed: 0 }
for (const s of $sessions) c[bucket(s)]++
return c
})
const visible = $derived(
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
)
function openTask(id: string) {
loadSessionMessages(id)
location.hash = '#/chat'
}
// ── New task entry ──────────────────────────────────────────────────────
let input = $state('')
function submit() {
const text = input.trim()
if (!text) return
input = ''
newChat()
sendMessage(text)
location.hash = '#/chat'
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
onMount(() => {
loadSummary()
loadSessions()
const unsubStream = subscribeEvents()
const summaryTimer = setInterval(loadSummary, 15000)
// Refetch the board when a task's lifecycle changes anywhere. Scan all
// events newer than the last seen (entity.touched fires constantly and
// buries task.status); debounce a burst into one refetch.
let lastSeenId = 0
let refreshTimer: ReturnType<typeof setTimeout> | null = null
const unsub = liveEvents.subscribe((evs) => {
if (evs.length === 0) return
const maxId = evs[0].id
if (maxId <= lastSeenId) return
const relevant = evs.some((e) => e.id > lastSeenId && TASK_EVENTS.has(e.type))
lastSeenId = maxId
if (relevant) {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => loadSessions(), 400)
}
})
return () => {
unsub()
unsubStream()
clearInterval(summaryTimer)
}
})
</script>
<div class="@container/main flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
<h1 class="text-lg font-semibold">Overview</h1>
<div class="relative h-full overflow-hidden">
<GraphBackground />
{#if loading}
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
{#each Array(4) as _}
<Skeleton class="h-28 w-full" />
{/each}
</div>
{:else if summary}
<div
class="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Entities</Card.Description>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{totalEntities}
</Card.Title>
<Card.Action>
<Badge variant="outline">{entityTypeCount} types</Badge>
</Card.Action>
</Card.Header>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
{#each topTypes as [type, count]}
<span class="text-muted-foreground">{type}: <span class="text-foreground">{count}</span></span>
{/each}
</div>
<div class="text-muted-foreground">Across the fleet</div>
</Card.Footer>
</Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Fleet health</Card.Description>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{summary.health.healthy} / {totalMonitored}
</Card.Title>
<Card.Action>
{#if healthTone === 'ok'}
<Badge variant="outline"><CircleCheckIcon class="text-success" />healthy</Badge>
{:else if healthTone === 'degraded'}
<Badge variant="outline"><TriangleAlertIcon class="text-warning" />degraded</Badge>
{:else}
<Badge variant="outline"><OctagonXIcon class="text-destructive" />down</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{summary.health.degraded} degraded · {summary.health.down} down · {summary.health.unknown} unmonitored
</div>
<div class="text-muted-foreground">Healthy entities as observed by the scheduler</div>
</Card.Footer>
</Card.Root>
<button type="button" class="text-left" onclick={() => (location.hash = '#/signals')}>
<Card.Root class="@container/card transition-colors hover:border-primary/50">
<Card.Header>
<Card.Description>Open signals</Card.Description>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{totalSignals}
</Card.Title>
<Card.Action>
{#if worstSeverity === 'critical'}
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
{:else if worstSeverity === 'warning'}
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
{:else}
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
{/each}
</div>
<div class="text-muted-foreground">Unresolved right now</div>
</Card.Footer>
</Card.Root>
</button>
<button type="button" class="text-left" onclick={() => (location.hash = '#/ops')}>
<Card.Root class="@container/card transition-colors hover:border-primary/50">
<Card.Header>
<Card.Description>Pending approvals</Card.Description>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{summary.approvals_pending}
</Card.Title>
<Card.Action>
{#if summary.approvals_pending > 0}
<Badge variant="destructive">needs review</Badge>
{:else}
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{executionsRunning} running · {executionsFailed} failed
</div>
<div class="text-muted-foreground">Executions in the last 24h</div>
</Card.Footer>
</Card.Root>
</button>
</div>
{#if degradedTypes.length}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Attention needed</Card.Title>
</Card.Header>
<Card.Content class="flex gap-2">
{#each degradedTypes as t}
<Badge variant={t.tone === 'text-destructive' ? 'destructive' : 'secondary'}
>{t.label}: {t.count}</Badge
>
{/each}
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Event rate (6h, 5m buckets)</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex h-16 items-end gap-0.5">
{#each summary.event_rate as bucket}
<div
class="flex-1 rounded-t bg-primary/60"
style="height: {Math.max((bucket.count / maxEventRate) * 100, 2)}%"
title="{bucket.bucket}: {bucket.count} events"
></div>
{/each}
<div class="relative z-10 h-full overflow-y-auto">
<!-- Hero: fills the viewport. The input is pinned to true vertical center via the
grid's middle 1fr row; the metrics strip and scroll hint sit in the auto rows
above/below without shifting it off-center. Scrolling lifts the whole hero to
reveal the table. -->
<section class="grid min-h-full grid-rows-[auto_1fr_auto] gap-6 px-4 py-16">
<!-- Metrics strip -->
<div class="flex flex-wrap items-center justify-center gap-2 text-xs">
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
<span class="text-muted-foreground">Entities</span>
<span class="font-semibold tabular-nums">{totalEntities}</span>
{#if entityTypeCount}<span class="text-muted-foreground">· {entityTypeCount} types</span>{/if}
</div>
</Card.Content>
</Card.Root>
{/if}
<Card.Root class="flex-1">
<Card.Header>
<Card.Title class="text-sm">Live event ticker</Card.Title>
</Card.Header>
<Card.Content class="p-0">
<ScrollArea class="h-64 px-4 pb-4">
<div class="flex flex-col gap-1">
{#each $liveEvents as ev (ev.id)}
<div class="flex items-center gap-2 text-xs">
<Badge variant={severityVariant(ev.severity)} class="shrink-0"
>{ev.severity}</Badge
>
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
<span>{ev.type}</span>
<span class="truncate text-muted-foreground">{ev.source}</span>
</div>
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
<span
class="size-2 rounded-full {healthTone === 'ok' ? 'bg-success' : healthTone === 'degraded' ? 'bg-warning' : 'bg-destructive'}"
></span>
<span class="text-muted-foreground">Health</span>
<span class="font-semibold tabular-nums">{summary?.health.healthy ?? 0} / {totalMonitored}</span>
</div>
<button
type="button"
onclick={() => (location.hash = '#/signals')}
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
>
{#if worstSeverity === 'critical'}
<TriangleAlertIcon class="size-3.5 text-destructive" />
{:else if worstSeverity === 'warning'}
<TriangleAlertIcon class="size-3.5 text-warning" />
{:else}
<p class="text-xs text-muted-foreground">Waiting for events…</p>
<CircleCheckIcon class="size-3.5 text-success" />
{/if}
<span class="text-muted-foreground">Signals</span>
<span class="font-semibold tabular-nums">{totalSignals}</span>
</button>
<button
type="button"
onclick={() => (location.hash = '#/ops')}
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
>
<span class="text-muted-foreground">Approvals</span>
<span class="font-semibold tabular-nums {summary?.approvals_pending ? 'text-destructive' : ''}"
>{summary?.approvals_pending ?? 0}</span
>
</button>
</div>
<!-- New task entry: centered in the middle (1fr) row -->
<div class="flex items-center justify-center">
<div class="w-full max-w-2xl text-center">
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
<p class="mb-4 text-sm text-muted-foreground">
Describe a goal — Nomos will plan it, execute it, and report the outcome.
</p>
<form
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
rows={3}
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
/>
<div class="flex items-center justify-between px-3 pb-3">
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
<ArrowUpIcon />
</Button>
</div>
</form>
</div>
</div>
<span class="justify-self-center text-[11px] text-muted-foreground/70">Scroll to see all tasks ↓</span>
</section>
<!-- Task table -->
<section class="mx-auto w-full max-w-5xl px-4 pb-16">
<div class="rounded-xl border bg-card/70 backdrop-blur">
<div class="flex flex-wrap items-center gap-1.5 border-b p-3">
{#each FILTERS as f}
<button
type="button"
onclick={() => (filter = f.id)}
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50'}"
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
</button>
{/each}
</div>
</ScrollArea>
</Card.Content>
</Card.Root>
{#if visible.length === 0}
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one above and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
</tr>
</thead>
<tbody>
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s.id)}
>
<td class="px-4 py-2.5">
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>
</td>
<td class="max-w-0 px-4 py-2.5">
<span class="line-clamp-1 font-medium">{heading(s)}</span>
</td>
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
{relativeTime(s.last_active_at)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</section>
</div>
</div>

View File

@@ -1,213 +0,0 @@
<script lang="ts">
import { sessions, loadSessions, loadSessionMessages, deleteSession, newChat } from '$lib/stores/chat'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import type { Session } from '$lib/api'
import { relativeTime } from '$lib/utils'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import * as Card from '$lib/components/ui/card'
import PlusIcon from '@lucide/svelte/icons/plus'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
import { onMount } from 'svelte'
// Live board: refetch when a task's lifecycle changes anywhere (the agent set
// a goal, advanced status, raised/answered a question, finished). We subscribe
// to the event store directly rather than via $effect so delivery is
// deterministic. We must scan ALL events newer than the last we saw, not just
// liveEvents[0]: entity.touched fires on every tool call, so a task.status
// event is usually buried below several touches by the time we're notified. A
// short debounce coalesces one task's goal.set + plan.proposed + task.status
// burst into a single refetch.
const TASK_EVENTS = new Set(['task.status', 'goal.set', 'question.raised', 'question.answered'])
onMount(() => {
loadSessions()
const unsubStream = subscribeEvents() // keep the global stream open while the board is up
let lastSeenId = 0
let refreshTimer: ReturnType<typeof setTimeout> | null = null
const unsub = liveEvents.subscribe((evs) => {
if (evs.length === 0) return
const maxId = evs[0].id // newest-first
if (maxId <= lastSeenId) return
const relevant = evs.some((e) => e.id > lastSeenId && TASK_EVENTS.has(e.type))
lastSeenId = maxId
if (relevant) {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => loadSessions(), 400)
}
})
return () => {
unsub()
unsubStream()
}
})
// ── Status → display ────────────────────────────────────────────────
type Bucket = 'running' | 'input' | 'done' | 'failed'
function bucket(s: Session): Bucket {
switch (s.status) {
case 'awaiting_input':
return 'input'
case 'done':
return s.outcome === 'failure' ? 'failed' : 'done'
case 'failed':
return 'failed'
default:
return 'running' // active | planning | executing | undefined
}
}
interface StatusStyle {
label: string
dot: string
pulse: boolean
variant: 'default' | 'secondary' | 'destructive' | 'outline'
}
function statusStyle(s: Session): StatusStyle {
switch (bucket(s)) {
case 'input':
return { label: 'Needs input', dot: 'bg-warning', pulse: true, variant: 'secondary' }
case 'done':
return { label: s.outcome === 'partial' ? 'Done · partial' : 'Done', dot: 'bg-success', pulse: false, variant: 'default' }
case 'failed':
return { label: 'Failed', dot: 'bg-destructive', pulse: false, variant: 'destructive' }
default:
return { label: 'Running', dot: 'bg-primary', pulse: true, variant: 'secondary' }
}
}
const FILTERS: { id: 'all' | Bucket; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'running', label: 'Running' },
{ id: 'input', label: 'Needs input' },
{ id: 'done', label: 'Done' },
{ id: 'failed', label: 'Failed' }
]
let filter = $state<'all' | Bucket>('all')
const counts = $derived.by(() => {
const c: Record<string, number> = { all: $sessions.length, running: 0, input: 0, done: 0, failed: 0 }
for (const s of $sessions) c[bucket(s)]++
return c
})
const visible = $derived(
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
)
function heading(s: Session): string {
return s.goal || s.title || 'Untitled task'
}
function openTask(id: string) {
loadSessionMessages(id)
location.hash = '#/chat'
}
function startTask() {
newChat()
location.hash = '#/chat'
}
let confirmDelete = $state<string | null>(null)
function handleDelete(e: MouseEvent, id: string) {
e.stopPropagation()
if (confirmDelete === id) {
deleteSession(id)
confirmDelete = null
} else {
confirmDelete = id
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
}
}
</script>
<div class="mx-auto flex h-full min-h-0 max-w-6xl flex-col p-4 sm:p-6">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">Tasks</h2>
<p class="text-sm text-muted-foreground">Every task is a goal Nomos works to completion.</p>
</div>
<Button onclick={startTask} class="gap-1.5">
<PlusIcon class="size-4" />
New task
</Button>
</div>
<!-- Filter chips -->
<div class="mb-4 flex flex-wrap gap-1.5">
{#each FILTERS as f}
<button
type="button"
onclick={() => (filter = f.id)}
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50'}"
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
</button>
{/each}
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if visible.length === 0}
<div class="flex flex-col items-center gap-4 pt-20 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
{#if filter === 'all'}
<Button onclick={startTask} variant="outline" class="gap-1.5">
<PlusIcon class="size-4" /> Start your first task
</Button>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<div class="group relative">
<button type="button" class="block w-full text-left" onclick={() => openTask(s.id)}>
<Card.Root class="h-full transition-colors hover:border-primary/50">
<Card.Header class="pb-2">
<div class="flex items-center justify-between gap-2">
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>
<span class="text-[11px] text-muted-foreground">{relativeTime(s.last_active_at)}</span>
</div>
<Card.Title class="line-clamp-2 text-sm leading-snug">{heading(s)}</Card.Title>
</Card.Header>
<Card.Content class="pt-0">
{#if s.summary}
<p class="line-clamp-3 text-xs text-muted-foreground">{s.summary}</p>
{:else if s.goal && s.title && s.goal !== s.title}
<p class="line-clamp-2 text-xs text-muted-foreground">{s.title}</p>
{:else}
<p class="text-xs italic text-muted-foreground/60">No summary yet.</p>
{/if}
</Card.Content>
</Card.Root>
</button>
<button
type="button"
class="absolute right-2 top-2 flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
onclick={(e) => handleDelete(e, s.id)}
title={confirmDelete === s.id ? 'Click again to confirm' : 'Delete task'}
aria-label="Delete task"
>
{#if confirmDelete === s.id}
<span class="text-[10px] font-bold text-destructive">Del?</span>
{:else}
<Trash2Icon class="size-4" />
{/if}
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>