feat: group tool calls per turn + session entity graph in chat rail
Two chat UX changes (share Chat.svelte, committed together). Tool-call grouping (ToolCallGroup.svelte): - Replaced the one-<details>-per-tool-call list with a single collapsible group per assistant turn, headed by tool count + a name preview and a status icon (spinning wrench in progress, check done, X on error). - The group auto-collapses the instant its turn finishes streaming, so a completed round shows as one compact pill; historical/loaded turns start collapsed. The auto-collapse fires once at the streaming→done transition, leaving manual toggles alone afterward. Session graph rail (SessionGraph.svelte) — replaces the old ContextRail (fleet health / pending approvals / live events), which is deleted: - A force-directed graph that starts empty (animated constellation empty state) and grows as the conversation references entities. Slugs are extracted from message text and tool *arguments* only — never bulk result rows, so a single get_health_summary doesn't dump all 168 entities — then validated against the backend via fetchGraph (cached) with check/execution probe entities excluded. Nodes are colored by health; edges appear once both endpoints are present. - Clicking a node highlights it and its neighbors and opens an inline detail panel below: slug/type/state, health + freshness, top attributes, in-graph relations (clickable to hop), and a Full detail button opening the entity sheet. - The rail is resizable via a drag handle (260–620px, persisted to localStorage). The header's global fleet-health dots are unchanged; only the right-rail content was replaced. Risk: reversible_low (UI-only). The slug extractor is scoped to focused mentions by design; edges may be slightly incomplete since only root-fetched entities contribute edges, which is acceptable for a session overview. Verification: verified in the browser preview — loading a real session built a 4-node graph (hubris/caddy/netbird-vps/strong) with the hubris→caddy relationship edge; clicking hubris showed "proxmox-host · active · healthy · checked 40s ago" with attributes and relations; dragging the handle resized 320→440px and persisted; a 34-tool historical turn renders as one collapsed pill that expands on click. tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
79
web/src/lib/components/ToolCallGroup.svelte
Normal file
79
web/src/lib/components/ToolCallGroup.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
// active = this message is the one currently streaming a round of tool
|
||||
// calls. The group starts open while active (so progress is visible live)
|
||||
// and auto-collapses the moment that round finishes; a loaded/historical
|
||||
// message is never active, so it starts collapsed. Once the effect below
|
||||
// fires the one-time auto-collapse, manual toggles are left alone.
|
||||
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
|
||||
|
||||
let open = $state(active)
|
||||
let wasActive = active
|
||||
|
||||
$effect(() => {
|
||||
if (wasActive && !active) {
|
||||
open = false
|
||||
}
|
||||
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 inProgress = $derived(active && doneCount < tools.length)
|
||||
const names = $derived(tools.map((t) => t.name).join(', '))
|
||||
|
||||
function toolSummary(args: unknown): string {
|
||||
if (!args || typeof args !== 'object') return ''
|
||||
return Object.entries(args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join(' ')
|
||||
.slice(0, 80)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if tools.length}
|
||||
<details bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
||||
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
|
||||
{#if inProgress}
|
||||
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
||||
{:else if hasError}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
{:else}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
{/if}
|
||||
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
|
||||
<span class="max-w-64 truncate font-mono text-muted-foreground">{names}</span>
|
||||
<ChevronDownIcon class="size-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div class="flex flex-col divide-y border-t">
|
||||
{#each tools 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" />
|
||||
{:else if tool.type === 'tool_result'}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
{:else}
|
||||
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
||||
{/if}
|
||||
<span class="font-mono font-medium">{tool.name}</span>
|
||||
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
||||
</div>
|
||||
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
|
||||
{#if tool.args}
|
||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
|
||||
{/if}
|
||||
{#if tool.type === 'tool_result'}
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user