feat(chat): MCP tool apps — custom inline renderers for 12 tools
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

12 of 33 MCP tools now render as rich inline cards instead of raw JSON:
EntityCard, HealthSummary, LXCList, EntityTable, KnowledgeResults,
BlastRadius, ChangeLog, FleetSnapshot, MetricChart.

Architecture:
- Server: annotateJSONResult() wraps queryRows with __renderer hints
- Registry: match/dispatch system maps tool names to Svelte components
- Chat: inline dispatch with 5-card limit, overflow to collapsed group
- ToolCallGroup: unmatched prop, hides when all matched, ARIA labels

Tests: 3 new Go tests for annotateJSONResult (wrap, no-op, multi-row).
This commit is contained in:
2026-07-13 22:46:56 +02:00
parent 680575e2cf
commit 8b50753746
27 changed files with 1092 additions and 43 deletions

View File

@@ -7,11 +7,14 @@
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
let open = $state(false)
let wasActive = $state(active)
const bodyTools = $derived(unmatched ?? tools)
const inlineCount = $derived(tools.length - bodyTools.length)
$effect(() => {
if (active && !wasActive) {
open = true
@@ -22,18 +25,18 @@
wasActive = active
})
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
const names = $derived(tools.map((t) => t.name).join(', '))
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
const names = $derived(bodyTools.map((t) => t.name).join(', '))
const runningTool = $derived(
active ? tools.find((t) => t.type === 'tool_use') : undefined
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
)
const ariaLabel = $derived(
doneCount === tools.length
? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed`
: `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done`
doneCount === bodyTools.length
? `${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} completed`
: `${doneCount}/${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} done`
)
function toolSummary(args: unknown): string {
@@ -45,19 +48,19 @@
}
</script>
{#if tools.length}
{#if bodyTools.length}
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
{#if active && doneCount < tools.length}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
{#if active && doneCount < bodyTools.length}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
{:else if hasError}
<XIcon class="size-3 shrink-0 text-destructive" />
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
{:else}
<CheckIcon class="size-3 shrink-0 text-success" />
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
{/if}
{#if active && doneCount < tools.length}
<span class="font-medium">{doneCount}/{tools.length}</span>
{#if active && doneCount < bodyTools.length}
<span class="font-medium">{doneCount}/{bodyTools.length}</span>
{#if runningTool}
<span class="max-w-48 truncate font-mono text-muted-foreground">
{runningTool.name}
@@ -67,7 +70,10 @@
<span class="animate-pulse text-muted-foreground">working…</span>
{/if}
{:else}
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
<span class="font-medium">{bodyTools.length} tool{bodyTools.length === 1 ? '' : 's'}</span>
{#if inlineCount > 0}
<span class="text-muted-foreground">· {inlineCount} card{inlineCount === 1 ? '' : 's'} shown</span>
{/if}
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
{/if}
@@ -78,16 +84,16 @@
</Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
<div class="flex flex-col divide-y border-t">
{#each tools as tool (tool.id)}
<div class="flex flex-col divide-y border-t" role="list" aria-label={ariaLabel}>
{#each bodyTools as tool (tool.id)}
<div class="p-2">
<div class="flex items-center gap-2">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
{:else}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const grouped = $derived.by(() => {
if (!rows) return null
const g: Record<number, string[]> = {}
for (const r of rows) {
const d = Number(r.depth) || 0
if (!g[d]) g[d] = []
g[d].push(r.slug)
}
return g
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const total = $derived(rows?.length ?? 0)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="animate-pulse text-muted-foreground">calculating…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Blast radius error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="text-destructive">{error}</span>
</div>
{:else if grouped && total > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
<div class="px-3 py-2">
<div class="mb-1 font-medium text-muted-foreground">
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
</div>
<div class="flex flex-wrap gap-1">
{#each slugs as slug}
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="text-muted-foreground">no affected entities found</span>
</div>
{/if}

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
function shortTs(ts: string): string {
try {
const d = new Date(ts)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ts?.slice(11, 19) ?? ''
}
}
function shortDate(ts: string): string {
return ts?.slice(0, 10) ?? ''
}
function shortId(id: string): string {
if (!id) return ''
return id.length > 12 ? id.slice(0, 12) : id
}
const activityRows = $derived.by(() => {
if (!rows) return null
return rows.map((r) => ({
time: shortTs(r.timestamp || r.ts || ''),
date: shortDate(r.timestamp || r.ts || ''),
actor: r.actor_label || shortId(r.agent_id) || r.actor_type || '',
action: r.action || r.activity_type || '',
toolName: r.tool_name || r.path || '',
status: r.success ?? (r.error ? 'false' : undefined),
}))
})
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading activity log">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Activity log error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="text-destructive">{error}</span>
</div>
{:else if activityRows && activityRows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Activity log: {activityRows.length} entries">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{activityRows.length} entr{activityRows.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each activityRows as row}
<div class="flex items-center gap-2 px-3 py-1.5 font-mono">
<span class="shrink-0 text-muted-foreground">{row.time}</span>
{#if row.date !== activityRows[0].date}
<span class="shrink-0 text-[10px] text-muted-foreground/60">{row.date}</span>
{/if}
<span class="text-muted-foreground">{row.action}</span>
<span class="max-w-32 truncate">{row.toolName}</span>
<span class="text-muted-foreground/60">{row.actor}</span>
{#if row.status === 'true'}
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-success" title="success"></span>
{:else if row.status === 'false'}
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-destructive" title="error"></span>
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Activity log: no entries">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="text-muted-foreground">no entries</span>
</div>
{/if}

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import { Badge } from '$lib/components/ui/badge'
import { relativeTime } from '$lib/utils'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import BoxIcon from '@lucide/svelte/icons/box'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import ContainerIcon from '@lucide/svelte/icons/container'
import GlobeIcon from '@lucide/svelte/icons/globe'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import ZapIcon from '@lucide/svelte/icons/zap'
let { tool }: { tool: ToolCallResult } = $props()
const entity = $derived.by(() => {
if (tool.type !== 'tool_result') return null
const r = tool.result
if (!r) return null
if (Array.isArray(r)) return r[0]
if (r && typeof r === 'object' && 'data' in r) return Array.isArray(r.data) ? r.data[0] : r.data
return r
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const slug = $derived(entity?.slug ?? tool.args?.slug_or_id ?? tool.args?.hostname ?? tool.args?.service_slug ?? '')
const typeIcon: Record<string, typeof BoxIcon> = {
host: MonitorIcon,
lxc: ContainerIcon,
service: GlobeIcon,
check: ZapIcon,
}
const Icon = $derived(entity?.type ? (typeIcon[entity.type] ?? BoxIcon) : BoxIcon)
const keyAttrs = $derived.by(() => {
if (!entity) return [] as [string, string][]
const out: [string, string][] = []
const skip = new Set(['slug', 'type', 'name', 'state', 'health', 'last_check', 'version', 'created_at', 'updated_at', 'maintenance_until', '__renderer', 'data', 'attrs', 'attributes', 'enrolled_at'])
for (const k of ['mesh_ip', 'ip', 'version', 'age_pubkey', 'enrolled_at', 'last_check']) {
const v = entity[k]
if (v && typeof v === 'string') {
out.push([k, k === 'age_pubkey' ? v.slice(0, 16) + '…' : v])
}
}
const attrs = entity.attributes ?? entity.attrs
if (attrs && typeof attrs === 'object') {
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
if (!skip.has(k) && v != null && v !== '') {
out.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
}
}
}
return out.slice(0, 4)
})
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entity: {slug || tool.name}">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entity: {error}">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="text-destructive">{error}</span>
</div>
{:else if entity}
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Entity: {entity.slug}{entity.type}{entity.health || 'no health data'}">
<div class="flex flex-wrap items-center gap-1.5">
<div class="flex items-center gap-1.5">
<Icon class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="font-mono font-semibold">{entity.slug ?? slug}</span>
</div>
{#if entity.type}
<Badge variant="outline" class="text-[10px]">{entity.type}</Badge>
{/if}
{#if entity.state}
<Badge variant="secondary" class="text-[10px]">{entity.state}</Badge>
{/if}
{#if entity.health && entity.health !== 'unknown'}
<span class="flex items-center gap-1 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {healthColor[entity.health] ?? 'var(--muted-foreground)'}"></span>
{entity.health}
</span>
{/if}
{#if entity.last_check}
<span class="text-muted-foreground">· {relativeTime(entity.last_check)}</span>
{/if}
</div>
{#if keyAttrs.length > 0}
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground">
{#each keyAttrs as [k, v]}
<span class="font-mono text-[10px]"><span class="opacity-60">{k}:</span> {v}</span>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Entity: {slug || tool.name} — no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const cols = $derived(rows && rows.length > 0 ? Object.keys(rows[0]).filter(k => k !== '__renderer') : [])
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entities">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Entities</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entities">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Entities</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Entities: {rows.length} results">
<div class="max-h-56 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
{#each cols as col}
<th class="px-2 py-1 text-left font-medium whitespace-nowrap">{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
{#each cols as col}
<td class="px-2 py-1 whitespace-nowrap font-mono max-w-48 truncate">{row[col] ?? '—'}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs text-muted-foreground" role="status" aria-label="Entities: no results">
<XIcon class="size-3 shrink-0" aria-hidden="true" />
<span>No entities found</span>
</div>
{/if}

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const summary = $derived.by(() => {
if (!rows) return null
const health: Record<string, number> = {}
const types: Record<string, number> = {}
for (const r of rows) {
health[r.health || 'unknown'] = (health[r.health || 'unknown'] || 0) + 1
types[r.type || 'unknown'] = (types[r.type || 'unknown'] || 0) + 1
}
return { health, types, total: rows.length }
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
unknown: 'var(--muted-foreground)',
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading fleet snapshot">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Fleet snapshot error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="text-destructive">{error}</span>
</div>
{:else if summary && summary.total > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Fleet snapshot: {summary.total} entities">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{summary.total} entities</span>
</div>
<div class="px-3 py-2 space-y-2">
<!-- Health -->
<div>
<div class="text-muted-foreground mb-1">Health</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
{#if summary.health[h]}
<div class="flex items-center gap-1.5 font-mono">
<span class="size-1.5 rounded-full" style="background: {healthColor[h] ?? 'var(--muted-foreground)'}"></span>
<span class="text-muted-foreground">{h}</span>
<span class="tabular-nums">{summary.health[h]}</span>
</div>
{/if}
{/each}
</div>
</div>
<!-- Types -->
{#if Object.keys(summary.types).length > 0}
<div>
<div class="text-muted-foreground mb-1">By type</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each Object.entries(summary.types).sort(([,a], [,b]) => b - a) as [type, count]}
<div class="flex items-center gap-1.5 font-mono">
<span class="text-muted-foreground">{type}</span>
<span class="tabular-nums">{count}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Fleet snapshot: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data : null
})
const counts = $derived.by(() => {
if (!rows) return null
const m: Record<string, number> = {}
for (const r of rows as any[]) m[r.health || 'unknown'] = (m[r.health || 'unknown'] || 0) + 1
return m
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const total = $derived(counts ? Object.values(counts).reduce((a, b) => a + b, 0) : 0)
const bars: { label: string; count: number; color: string }[] = [
{ label: 'healthy', count: counts?.healthy ?? 0, color: 'var(--success)' },
{ label: 'degraded', count: counts?.degraded ?? 0, color: 'var(--warning)' },
{ label: 'down', count: counts?.down ?? 0, color: 'var(--destructive)' },
{ label: 'unknown', count: counts?.unknown ?? 0, color: 'var(--muted-foreground)' },
]
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading health summary">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading health summary">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="text-destructive">{error}</span>
</div>
{:else if counts && total > 0}
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Health summary: {total} entities — healthy {counts?.healthy ?? 0}, degraded {counts?.degraded ?? 0}, down {counts?.down ?? 0}">
<div class="flex items-center gap-2 mb-1.5">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{total} entities</span>
</div>
<div class="flex h-5 w-full overflow-hidden rounded">
{#each bars as bar}
{#if bar.count > 0}
<div
style="width: {(bar.count / total) * 100}%; background: {bar.color}"
class="flex items-center justify-center text-[9px] font-medium text-white min-w-[2rem]"
title="{bar.label}: {bar.count}"
>
{bar.count}
</div>
{/if}
{/each}
</div>
<div class="mt-1.5 flex flex-wrap gap-x-3 text-muted-foreground">
{#each bars as bar}
{#if bar.count > 0}
<span class="flex items-center gap-1">
<span class="size-1.5 rounded-full" style="background: {bar.color}"></span>
{bar.label} {bar.count}
</span>
{/if}
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Health summary: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import FileTextIcon from '@lucide/svelte/icons/file-text'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Searching knowledge">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="animate-pulse text-muted-foreground">searching…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Knowledge search error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Knowledge: {rows.length} results">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} result{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each rows as row}
<div class="px-3 py-2">
<div class="flex items-start gap-2">
<FileTextIcon class="size-3 shrink-0 mt-0.5 text-muted-foreground" />
<div class="min-w-0">
<div class="font-mono font-medium truncate">{row.title}</div>
{#if row.snippet || row.headline}
<div class="mt-0.5 text-muted-foreground leading-relaxed line-clamp-2">
{row.snippet || row.headline}
</div>
{/if}
<div class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
{#if row.source}
<span>{row.source}</span>
{/if}
{#if row.slug}
<span class="font-mono opacity-60">{row.slug}</span>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Knowledge: no results">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="text-muted-foreground">no results</span>
</div>
{/if}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
}
function shortName(slug: string): string {
return slug.split(':').pop() ?? slug
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading LXC containers">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading LXC containers">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="LXC containers: {rows.length} total">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} container{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-48 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
<th class="px-3 py-1 text-left font-medium">Name</th>
<th class="px-3 py-1 text-left font-medium">ID</th>
<th class="px-3 py-1 text-left font-medium">IP</th>
<th class="px-3 py-1 text-left font-medium">Health</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="px-3 py-1 font-mono">{shortName(row.slug)}</td>
<td class="px-3 py-1 tabular-nums text-muted-foreground">{row.pve_id ?? '—'}</td>
<td class="px-3 py-1 font-mono text-muted-foreground">{row.lan_ip ?? '—'}</td>
<td class="px-3 py-1">
{#if row.health}
<span class="flex items-center gap-1">
<span class="size-1.5 rounded-full" style="background: {healthColor[row.health] ?? 'var(--muted-foreground)'}"></span>
{row.health}
</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="LXC containers: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading metrics">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Metrics error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Metrics: {rows.length} samples">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} sample{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-48 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
<th class="px-2 py-1 text-left font-medium">Time</th>
<th class="px-2 py-1 text-left font-medium">Metric</th>
<th class="px-2 py-1 text-right font-medium">Avg</th>
<th class="px-2 py-1 text-right font-medium">Min</th>
<th class="px-2 py-1 text-right font-medium">Max</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="px-2 py-1 font-mono tabular-nums whitespace-nowrap">{row.bucket?.slice(11, 16) ?? row.bucket?.slice(0, 19) ?? '—'}</td>
<td class="px-2 py-1 font-mono max-w-32 truncate">{row.metric ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right">{row.avg ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.min ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.max ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Metrics: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import BlastRadius from './BlastRadius.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_blast_radius' || t.result?.__renderer === 'blast_radius',
component: BlastRadius,
})
}

View File

@@ -0,0 +1,12 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import ChangeLog from './ChangeLog.svelte'
export function init() {
registerToolRenderer({
match: (t) =>
t.name === 'get_change_history' ||
t.name === 'get_agent_activity' ||
t.result?.__renderer === 'change_log',
component: ChangeLog,
})
}

View File

@@ -0,0 +1,11 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import EntityCard from './EntityCard.svelte'
const TOOLS = ['get_entity', 'whoami', 'explain']
export function init() {
registerToolRenderer({
match: (t) => TOOLS.includes(t.name) || t.result?.__renderer === 'entity_card',
component: EntityCard,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import EntityTable from './EntityTable.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'list_entities' || t.result?.__renderer === 'entity_table',
component: EntityTable,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import FleetSnapshot from './FleetSnapshot.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_state_snapshot' || t.result?.__renderer === 'fleet_snapshot',
component: FleetSnapshot,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import HealthSummary from './HealthSummary.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_health_summary' || t.result?.__renderer === 'health_summary',
component: HealthSummary,
})
}

View File

@@ -0,0 +1,19 @@
import { init as initEntityCard } from './entity-card'
import { init as initHealthSummary } from './health-summary'
import { init as initLXCList } from './lxc-list'
import { init as initEntityTable } from './entity-table'
import { init as initKnowledgeResults } from './knowledge-results'
import { init as initBlastRadius } from './blast-radius'
import { init as initChangeLog } from './change-log'
import { init as initFleetSnapshot } from './fleet-snapshot'
import { init as initMetricChart } from './metric-chart'
initEntityCard()
initHealthSummary()
initLXCList()
initEntityTable()
initKnowledgeResults()
initBlastRadius()
initChangeLog()
initFleetSnapshot()
initMetricChart()

View File

@@ -0,0 +1,12 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import KnowledgeResults from './KnowledgeResults.svelte'
export function init() {
registerToolRenderer({
match: (t) =>
t.name === 'search_knowledge' ||
t.name === 'get_entity_knowledge' ||
t.result?.__renderer === 'knowledge_results',
component: KnowledgeResults,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import LXCList from './LXCList.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'list_lxcs' || t.result?.__renderer === 'lxc_list',
component: LXCList,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import MetricChart from './MetricChart.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'query_metrics' || t.result?.__renderer === 'metric_chart',
component: MetricChart,
})
}

View File

@@ -0,0 +1,17 @@
import type { Component } from 'svelte'
import type { ToolCallResult } from '$lib/stores/chat'
export interface ToolRenderer {
match: (tool: ToolCallResult) => boolean
component: Component<{ tool: ToolCallResult }>
}
const registry: ToolRenderer[] = []
export function registerToolRenderer(r: ToolRenderer) {
registry.push(r)
}
export function getToolRenderer(tool: ToolCallResult): ToolRenderer | undefined {
return registry.find((r) => r.match(tool))
}

View File

@@ -5,5 +5,7 @@ import { initConfig } from '$lib/config'
initConfig()
requestAnimationFrame(() => import('./lib/renderers'))
const app = mount(App, { target: document.getElementById('app')! })
export default app

View File

@@ -4,6 +4,7 @@
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import InlineApproval from '$lib/components/InlineApproval.svelte'
import { getToolRenderer } from '$lib/tool-renderers'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
@@ -16,6 +17,19 @@
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
const MAX_INLINE_CARDS = 5
function getInlineTools(msg: { tools: any[] }): any[] {
const matched = msg.tools.filter((t) => getToolRenderer(t))
if (matched.length <= MAX_INLINE_CARDS) return matched
return matched.slice(0, MAX_INLINE_CARDS)
}
function getRemaining(msg: { tools: any[] }, inline: any[]): any[] {
const inlineIds = new Set(inline.map((t) => t.id))
return msg.tools.filter((t) => !inlineIds.has(t.id))
}
// Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260
const RAIL_MAX = 620
@@ -113,7 +127,17 @@
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
{#each getInlineTools(msg) as tool (tool.id)}
{@const renderer = getToolRenderer(tool)}
{#if renderer}
<renderer.component {tool} />
{/if}
{/each}
<ToolCallGroup
tools={msg.tools}
unmatched={getRemaining(msg, getInlineTools(msg))}
active={$streaming && i === $messages.length - 1}
/>
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->