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>