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,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