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:
@@ -1,109 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { summary, pendingApprovals, subscribeContext, refreshContext } from '$lib/stores/context'
|
||||
import { liveEvents } from '$lib/stores/events'
|
||||
import { decideApproval } from '$lib/api'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
let deciding = $state<string | null>(null)
|
||||
|
||||
onMount(() => subscribeContext())
|
||||
|
||||
async function decide(id: string, decision: 'approve' | 'deny') {
|
||||
deciding = id
|
||||
const result = await decideApproval(id, decision)
|
||||
deciding = null
|
||||
if (result) {
|
||||
toast.success(`Approval ${decision === 'approve' ? 'approved' : 'denied'}`)
|
||||
refreshContext()
|
||||
} else {
|
||||
toast.error('Decision failed')
|
||||
}
|
||||
}
|
||||
|
||||
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (sev === 'critical') return 'destructive'
|
||||
if (sev === 'warning') return 'secondary'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const recentEvents = $derived($liveEvents.slice(0, 10))
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full w-72 shrink-0 flex-col gap-3 overflow-y-auto border-l bg-card/50 p-3">
|
||||
{#if $summary}
|
||||
<div>
|
||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Fleet health</p>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
|
||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
|
||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
|
||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-muted-foreground"></span>{$summary.health.unknown}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Pending approvals</p>
|
||||
{#if $pendingApprovals.length}
|
||||
<Badge variant="destructive" class="h-4 px-1.5 text-[10px]">{$pendingApprovals.length}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each $pendingApprovals.slice(0, 5) as approval (approval.id)}
|
||||
<div class="rounded-md border bg-background p-2">
|
||||
<p class="truncate font-mono text-[11px]">{approval.subject ?? approval.slug}</p>
|
||||
<p class="mb-1.5 text-xs">{approval.action} <Badge variant="outline" class="ml-1 h-4 px-1 text-[10px]">{approval.risk_class}</Badge></p>
|
||||
<div class="flex gap-1.5">
|
||||
<Button size="sm" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'approve')}>Approve</Button>
|
||||
<Button size="sm" variant="destructive" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'deny')}>Deny</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">Nothing waiting on you.</p>
|
||||
{/each}
|
||||
{#if $pendingApprovals.length > 5}
|
||||
<button type="button" class="text-left text-xs text-primary hover:underline" onclick={() => (location.hash = '#/ops')}>
|
||||
+{$pendingApprovals.length - 5} more in Operations
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $summary && Object.keys($summary.signals_by_severity).length}
|
||||
<Separator />
|
||||
<div>
|
||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Open signals</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each Object.entries($summary.signals_by_severity) as [severity, count]}
|
||||
<button type="button" onclick={() => (location.hash = '#/signals')}>
|
||||
<Badge variant={severityVariant(severity)} class="cursor-pointer">{severity}: {count}</Badge>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Separator />
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Live events</p>
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div class="flex flex-col gap-1.5 pr-2">
|
||||
{#each recentEvents as ev (ev.id)}
|
||||
<div class="text-[11px] leading-tight">
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
|
||||
<span class="ml-1 {ev.severity === 'critical' ? 'text-destructive' : ev.severity === 'warning' ? 'text-warning' : ''}">{ev.type}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">Quiet for now.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</aside>
|
||||
441
web/src/lib/components/SessionGraph.svelte
Normal file
441
web/src/lib/components/SessionGraph.svelte
Normal file
@@ -0,0 +1,441 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import {
|
||||
forceSimulation,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceX,
|
||||
forceY,
|
||||
type Simulation
|
||||
} from 'd3-force'
|
||||
import { fetchGraph, type Entity } from '$lib/api'
|
||||
import { messages } from '$lib/stores/chat'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
||||
|
||||
interface Node extends Entity {
|
||||
x?: number
|
||||
y?: number
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
degree: number
|
||||
}
|
||||
interface Edge {
|
||||
source: string | Node
|
||||
target: string | Node
|
||||
type: string
|
||||
}
|
||||
|
||||
// Probe/bookkeeping entity types are excluded — a health conversation
|
||||
// mentions dozens of check:… slugs that would swamp the fleet topology.
|
||||
const EXCLUDED = new Set(['check', 'execution'])
|
||||
|
||||
// Slug shape: lowercase type prefix, then one or more colon-separated
|
||||
// segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
|
||||
const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g
|
||||
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Edge[]>([])
|
||||
let selected = $state<Node | null>(null)
|
||||
let sheetSlug = $state<string | null>(null)
|
||||
let sheetOpen = $state(false)
|
||||
|
||||
let sim: Simulation<Node, Edge> | null = null
|
||||
|
||||
// Non-reactive caches (persist across message deltas). resolvedVersion is a
|
||||
// reactive counter bumped when async resolution finishes, so the reconcile
|
||||
// effect re-runs once entities come back.
|
||||
const resolvedCache = new Map<string, Node | null>()
|
||||
const edgeCache: { source: string; target: string; type: string }[] = []
|
||||
const edgeKeys = new Set<string>()
|
||||
const resolving = new Set<string>()
|
||||
let resolvedVersion = $state(0)
|
||||
|
||||
// container size drives the simulation coordinate space (1:1 with pixels so
|
||||
// node dragging maps cleanly regardless of the resizable panel width).
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
let cw = $state(300)
|
||||
let ch = $state(300)
|
||||
|
||||
function collectSlugs(value: unknown, out: Set<string>) {
|
||||
if (typeof value === 'string') {
|
||||
const m = value.match(SLUG_RE)
|
||||
if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) collectSlugs(v, out)
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const v of Object.values(value)) collectSlugs(v, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Only pull from what the conversation is *about*: message text and the
|
||||
// arguments the agent passed to tools — never bulk result rows (a single
|
||||
// get_health_summary would otherwise dump all 168 entities into the graph).
|
||||
const candidateSlugs = $derived.by(() => {
|
||||
const out = new Set<string>()
|
||||
for (const m of $messages) {
|
||||
collectSlugs(m.text, out)
|
||||
for (const t of m.tools) collectSlugs(t.args, out)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
async function resolveSlugs(slugs: string[]) {
|
||||
const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
|
||||
if (!todo.length) return
|
||||
for (const s of todo) resolving.add(s)
|
||||
await Promise.all(
|
||||
todo.map(async (s) => {
|
||||
try {
|
||||
const g = await fetchGraph({ root: s, depth: 1 })
|
||||
const root = g?.nodes.find((n) => n.slug === s) ?? null
|
||||
resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
|
||||
if (g && root) {
|
||||
for (const e of g.edges) {
|
||||
const k = `${e.source}|${e.target}|${e.type}`
|
||||
if (!edgeKeys.has(k)) {
|
||||
edgeKeys.add(k)
|
||||
edgeCache.push({ source: e.source, target: e.target, type: e.type })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
resolvedCache.set(s, null)
|
||||
} finally {
|
||||
resolving.delete(s)
|
||||
}
|
||||
})
|
||||
)
|
||||
resolvedVersion++
|
||||
}
|
||||
|
||||
function reconcile(cands: Set<string>) {
|
||||
const desired: Node[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const s of cands) {
|
||||
const e = resolvedCache.get(s)
|
||||
if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
|
||||
seen.add(e.slug)
|
||||
desired.push(e)
|
||||
}
|
||||
}
|
||||
const desiredSlugs = new Set(desired.map((e) => e.slug))
|
||||
const current = nodes
|
||||
const curSlugs = new Set(current.map((n) => n.slug))
|
||||
|
||||
let changed = desiredSlugs.size !== curSlugs.size
|
||||
if (!changed) for (const s of desiredSlugs) if (!curSlugs.has(s)) { changed = true; break }
|
||||
if (!changed) return
|
||||
|
||||
const bySlug = new Map(current.map((n) => [n.slug, n]))
|
||||
const ls = edgeCache
|
||||
.filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
|
||||
.map((e) => ({ ...e }))
|
||||
|
||||
const deg = new Map<string, number>()
|
||||
for (const l of ls) {
|
||||
deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
|
||||
deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const next = desired.map((e) => {
|
||||
const p = bySlug.get(e.slug)
|
||||
return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
|
||||
})
|
||||
|
||||
nodes = next
|
||||
links = ls
|
||||
if (selected && !desiredSlugs.has(selected.slug)) selected = null
|
||||
buildSim()
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const cands = candidateSlugs
|
||||
void resolvedVersion
|
||||
const missing = [...cands].filter((s) => !resolvedCache.has(s))
|
||||
if (missing.length) resolveSlugs(missing)
|
||||
untrack(() => reconcile(cands))
|
||||
})
|
||||
|
||||
function buildSim() {
|
||||
sim?.stop()
|
||||
if (!nodes.length) {
|
||||
sim = null
|
||||
return
|
||||
}
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Edge>(links).id((n) => n.slug).distance(48).strength(0.5))
|
||||
.force('charge', forceManyBody().strength(-150).distanceMax(240))
|
||||
.force('center', forceCenter(cw / 2, ch / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 6))
|
||||
.force('x', forceX(cw / 2).strength(0.06))
|
||||
.force('y', forceY(ch / 2).strength(0.06))
|
||||
.velocityDecay(0.34)
|
||||
.alphaDecay(0.045)
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
}
|
||||
|
||||
// keep the layout centred as the panel resizes
|
||||
$effect(() => {
|
||||
const w = cw
|
||||
const h = ch
|
||||
if (sim) {
|
||||
sim.force('center', forceCenter(w / 2, h / 2))
|
||||
sim.force('x', forceX(w / 2).strength(0.06))
|
||||
sim.force('y', forceY(h / 2).strength(0.06))
|
||||
sim.alpha(0.3).restart()
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!container) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const r = entries[0].contentRect
|
||||
cw = Math.max(r.width, 1)
|
||||
ch = Math.max(r.height, 1)
|
||||
})
|
||||
ro.observe(container)
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
healthy: 'var(--success)',
|
||||
degraded: 'var(--warning)',
|
||||
down: 'var(--destructive)',
|
||||
stale: 'var(--warning)',
|
||||
unknown: 'var(--muted-foreground)'
|
||||
}
|
||||
function nodeColor(n: Node): string {
|
||||
return n.health ? healthColor[n.health] ?? 'var(--muted-foreground)' : 'var(--muted-foreground)'
|
||||
}
|
||||
function nodeRadius(n: Node): number {
|
||||
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
|
||||
}
|
||||
function shortName(slug: string): string {
|
||||
return slug.split(':').pop() ?? slug
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
|
||||
}
|
||||
function endpointSlug(end: string | Node): string {
|
||||
return typeof end === 'object' ? end.slug : end
|
||||
}
|
||||
|
||||
// ─── drag / select ───────────────────────────────────────────────────
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
function toLocal(clientX: number, clientY: number) {
|
||||
const rect = container!.getBoundingClientRect()
|
||||
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||
}
|
||||
|
||||
function onNodeDown(e: PointerEvent, node: Node) {
|
||||
e.stopPropagation()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
dragState = { node, moved: false }
|
||||
sim?.alphaTarget(0.2).restart()
|
||||
}
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!dragState) return
|
||||
const p = toLocal(e.clientX, e.clientY)
|
||||
dragState.node.fx = p.x
|
||||
dragState.node.fy = p.y
|
||||
dragState.moved = true
|
||||
nodes = [...nodes]
|
||||
}
|
||||
function onUp() {
|
||||
if (!dragState) return
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selected = selected?.slug === node.slug ? null : node
|
||||
}
|
||||
|
||||
const selectedRelations = $derived(
|
||||
selected
|
||||
? links
|
||||
.filter((l) => endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug)
|
||||
.map((l) => {
|
||||
const outgoing = endpointSlug(l.source) === selected!.slug
|
||||
return { dir: outgoing ? '→' : '←', type: l.type, other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source) }
|
||||
})
|
||||
: []
|
||||
)
|
||||
|
||||
function openFull() {
|
||||
if (!selected) return
|
||||
sheetSlug = selected.slug
|
||||
sheetOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
||||
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Session graph</p>
|
||||
{#if nodes.length}
|
||||
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
||||
{#if nodes.length === 0}
|
||||
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
|
||||
<circle cx="60" cy="60" r="6" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<g stroke="currentColor" stroke-width="1" opacity="0.5">
|
||||
<line x1="60" y1="60" x2="26" y2="34"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="96" y2="40"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.4s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="34" y2="92"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="2.8s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="92" y2="90"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.1s" repeatCount="indefinite" /></line>
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<circle cx="26" cy="34" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="96" cy="40" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.4s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="34" cy="92" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="2.8s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="92" cy="90" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.1s" repeatCount="indefinite" /></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
||||
Entities Nomos explores in this conversation appear here, wired up by their relationships.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<svg
|
||||
width={cw}
|
||||
height={ch}
|
||||
viewBox="0 0 {cw} {ch}"
|
||||
class="h-full w-full touch-none select-none"
|
||||
role="application"
|
||||
aria-label="Session entity graph"
|
||||
onpointermove={onMove}
|
||||
onpointerup={onUp}
|
||||
onpointercancel={onUp}
|
||||
>
|
||||
<g>
|
||||
{#each links as link}
|
||||
{@const s = endpoint(link.source)}
|
||||
{@const t = endpoint(link.target)}
|
||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
|
||||
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
|
||||
<line
|
||||
x1={s.x}
|
||||
y1={s.y}
|
||||
x2={t.x}
|
||||
y2={t.y}
|
||||
stroke="var(--muted-foreground)"
|
||||
stroke-width={focus ? 1.6 : 1}
|
||||
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</line>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
<g>
|
||||
{#each nodes as node (node.slug)}
|
||||
{#if node.x != null && node.y != null}
|
||||
{@const r = nodeRadius(node)}
|
||||
{@const isSel = selected?.slug === node.slug}
|
||||
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
||||
<g
|
||||
transform="translate({node.x},{node.y})"
|
||||
class="cursor-pointer"
|
||||
opacity={dim ? 0.35 : 1}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => onNodeDown(e, node)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (selected = node)}
|
||||
>
|
||||
{#if isSel}
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
|
||||
<text
|
||||
y={r + 10}
|
||||
text-anchor="middle"
|
||||
font-size="9"
|
||||
fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width="2.5"
|
||||
class="pointer-events-none"
|
||||
>
|
||||
{shortName(node.slug)}
|
||||
</text>
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selected}
|
||||
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
|
||||
<Badge variant="outline">{selected.type}</Badge>
|
||||
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
|
||||
</div>
|
||||
{#if selected.health}
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span class="size-2 rounded-full" style="background: {nodeColor(selected)}"></span>
|
||||
{selected.health} · checked {relativeTime(selected.last_check_at)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selected.attributes && Object.keys(selected.attributes).length}
|
||||
<div>
|
||||
<p class="mb-1 font-medium text-muted-foreground">Attributes</p>
|
||||
<dl class="flex flex-col gap-1">
|
||||
{#each Object.entries(selected.attributes).slice(0, 6) as [key, value]}
|
||||
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
|
||||
<dd class="min-w-0 flex-1 truncate text-right">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedRelations.length}
|
||||
<div>
|
||||
<p class="mb-1 font-medium text-muted-foreground">Relations ({selectedRelations.length})</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each selectedRelations as rel}
|
||||
<div class="flex items-center gap-1 font-mono">
|
||||
<span class="text-muted-foreground">{rel.dir} {rel.type} →</span>
|
||||
<button type="button" class="truncate hover:underline" onclick={() => { const n = nodes.find((x) => x.slug === rel.other); if (n) selected = n }}>
|
||||
{rel.other}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button variant="outline" size="sm" class="w-full" onclick={openFull}>
|
||||
<ExternalLinkIcon class="mr-1 size-3.5" />
|
||||
Full detail
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />
|
||||
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}
|
||||
@@ -1,14 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||
import ContextRail from '$lib/components/ContextRail.svelte'
|
||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||
import SessionGraph from '$lib/components/SessionGraph.svelte'
|
||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
@@ -17,6 +15,35 @@
|
||||
let input = $state('')
|
||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||
|
||||
// Resizable right rail (session graph). Persisted so it survives reloads.
|
||||
const RAIL_MIN = 260
|
||||
const RAIL_MAX = 620
|
||||
function loadRailWidth(): number {
|
||||
if (typeof localStorage === 'undefined') return 320
|
||||
const v = Number(localStorage.getItem('oikos-rail-width'))
|
||||
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
|
||||
}
|
||||
let railWidth = $state(loadRailWidth())
|
||||
let resizing = $state(false)
|
||||
|
||||
function startResize(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
const startX = e.clientX
|
||||
const startW = railWidth
|
||||
function move(ev: PointerEvent) {
|
||||
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
|
||||
}
|
||||
function up() {
|
||||
resizing = false
|
||||
localStorage.setItem('oikos-rail-width', String(railWidth))
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void $messages
|
||||
void $streaming
|
||||
@@ -52,14 +79,6 @@
|
||||
if ($streaming) return
|
||||
sendMessage(q)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
@@ -87,35 +106,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each $messages as msg (msg.id)}
|
||||
{#each $messages as msg, i (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<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">
|
||||
{#each msg.tools as tool (tool.id)}
|
||||
<details class="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 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>
|
||||
</summary>
|
||||
<div class="max-h-48 overflow-y-auto border-t 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>
|
||||
</details>
|
||||
{/each}
|
||||
<ToolCallGroup tools={msg.tools} 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 -->
|
||||
@@ -174,8 +171,22 @@
|
||||
</div>
|
||||
|
||||
{#if showRail}
|
||||
<div class="hidden xl:block">
|
||||
<ContextRail />
|
||||
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
|
||||
<button
|
||||
type="button"
|
||||
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
|
||||
onpointerdown={startResize}
|
||||
aria-label="Resize session graph"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
|
||||
? 'bg-primary/60'
|
||||
: 'bg-border group-hover/rz:bg-primary/50'}"
|
||||
></span>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<SessionGraph />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user