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).
71 lines
3.0 KiB
Svelte
71 lines
3.0 KiB
Svelte
<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}
|