Files
oikos/web/src/lib/components/SessionDigest.svelte
dtoro 6a8efd22bb feat(web): redesign task rail — Plan/Event log polish, entity graph resize fix
- Plan/Activity panels: humanize step titles, richer icons, empty states
  matching Scope's illustration style, pretty-printed expandable detail
- Activity log renamed to Event log; every step now expandable
- Chat: middle-truncate header title, remove redundant task-list rail and
  header stat cluster (duplicated in the sidebar), simplify markdown styling
- Fix --font-mono actually being a monospace font (was aliased to DM Sans)
- Replace rotating loader-circle spinner with a smoother fading-blade Spinner
- SessionGraph entity detail panel: resizable and self-clamping against its
  live container size (was overflowing into sibling sections), close button
- Dev launch config: fetch bearer token from the running api container so
  `npm run dev` works against the local compose stack without a hardcoded
  secret in a tracked file

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 08:22:08 +02:00

216 lines
8.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
import { currentSession, streaming, toolTimeline, type ToolTimelineEntry } from '$lib/stores/chat'
import { currentTask, planSteps } from '$lib/stores/workspace'
import { Badge } from '$lib/components/ui/badge'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import CircleIcon from '@lucide/svelte/icons/circle'
import Spinner from './Spinner.svelte'
import WrenchIcon from '@lucide/svelte/icons/wrench'
let digest = $state<SessionDigest | null>(null)
let open = $state(false)
let openTools = $state(false)
let loadedKey = $state<string | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = $state(null)
$effect(() => {
const sid = $currentSession
const busy = $streaming
const status = $currentTask?.status ?? ''
if (!sid || busy) return
const key = `${sid}:${status}`
if (loadedKey !== key) {
loadedKey = key
fetchSessionDigest(sid).then((d) => (digest = d))
}
if (status !== 'done' && status !== 'failed') {
if (!pollTimer) pollTimer = setInterval(() => fetchSessionDigest(sid).then((d) => (digest = d)), 10000)
} else {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
return () => {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
})
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
if (status === 'completed') return 'default'
if (['running', 'approved'].includes(status)) return 'secondary'
return 'outline'
}
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
const planTotal = $derived($planSteps.length)
// Group tool timeline entries by message (turn), showing only unique tool names per entry.
const toolGroups = $derived.by(() => {
const groups: { msgIndex: number; entries: ToolTimelineEntry[] }[] = []
for (const e of $toolTimeline) {
const last = groups[groups.length - 1]
if (last && last.msgIndex === e.msgIndex) {
last.entries.push(e)
} else {
groups.push({ msgIndex: e.msgIndex, entries: [e] })
}
}
return groups
})
const toolCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use').length)
const runningCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use' && !$toolTimeline.some((r) => r.type === 'tool_result' && r.id === t.id)).length)
function toolSummary(t: ToolTimelineEntry): string {
if (!t.args || typeof t.args !== 'object') return t.name
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
if (typeof firstArg === 'string') return `${t.name} ${firstArg.slice(0, 40)}`
return t.name
}
</script>
{#if $streaming && $currentSession}
<div class="flex items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
<Spinner class="size-3 shrink-0 text-primary" />
<span>{toolCount} tool{toolCount === 1 ? '' : 's'} · {runningCount} running</span>
</div>
{/if}
{#if $currentTask?.outcome}
<div
class="flex items-start gap-2 border-b px-3 py-2 text-xs {$currentTask.outcome === 'failure'
? 'bg-destructive/5 text-destructive'
: $currentTask.outcome === 'partial'
? 'bg-warning/5 text-warning'
: 'bg-success/5 text-success'}"
>
{#if $currentTask.outcome === 'failure'}
<CircleXIcon class="mt-0.5 size-3.5 shrink-0" />
{:else}
<CircleCheckIcon class="mt-0.5 size-3.5 shrink-0" />
{/if}
<span class="leading-snug">{$currentTask.summary || `Task ${$currentTask.outcome}.`}</span>
</div>
{/if}
{#if $planSteps.length > 0}
<div class="flex shrink-0 flex-col gap-1 border-b px-3 py-2">
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span class="font-semibold uppercase tracking-wider">Plan</span>
<span>{planDone}/{planTotal}</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0}%"></div>
</div>
<ol class="flex flex-col gap-0.5">
{#each $planSteps as step (step.id)}
<li class="flex items-start gap-1.5 text-[11px] {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
<span class="mt-0.5 shrink-0">
{#if step.status === 'done'}
<CircleCheckIcon class="size-3 text-success" />
{:else if step.status === 'running'}
<Spinner class="size-3 text-primary" />
{:else if step.status === 'failed'}
<CircleXIcon class="size-3 text-destructive" />
{:else}
<CircleIcon class="size-3 text-muted-foreground" />
{/if}
</span>
<span class="leading-tight">{step.title}</span>
</li>
{/each}
</ol>
</div>
{/if}
{#if toolCount > 0}
<div class="border-b">
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
onclick={() => (openTools = !openTools)}
>
<span class="flex items-center gap-1.5">
{#if openTools}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
<WrenchIcon class="size-3 text-muted-foreground" />
Tool activity
</span>
<span class="text-muted-foreground">{toolCount} call{toolCount === 1 ? '' : 's'}</span>
</button>
{#if openTools}
<div class="flex flex-col gap-0.5 px-3 pb-2 text-xs">
{#each toolGroups as group}
{@const isLatest = group.msgIndex === toolGroups[toolGroups.length - 1]?.msgIndex}
<div class="rounded border px-2 py-1 {isLatest && $streaming ? 'border-primary/30 bg-primary/5' : ''}">
{#each group.entries as t (t.id)}
<div class="flex items-start gap-1.5 {t.type === 'tool_result' && t.error ? 'text-destructive' : ''}">
<span class="mt-0.5 shrink-0">
{#if t.type === 'tool_result' && t.error}
<CircleXIcon class="size-3 text-destructive" />
{:else if t.type === 'tool_result'}
<CircleCheckIcon class="size-3 text-success" />
{:else}
<Spinner class="size-3 text-primary" />
{/if}
</span>
<span class="font-mono text-[10px] truncate">{toolSummary(t)}</span>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if digest && digest.total_executions > 0}
<div class="border-b">
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
onclick={() => (open = !open)}
>
<span class="flex items-center gap-1.5">
{#if open}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
This session
</span>
<span class="flex items-center gap-1.5 text-muted-foreground">
{digest.total_executions} execution{digest.total_executions === 1 ? '' : 's'}
{#if digest.knowledge_created.length}
<span class="flex items-center gap-0.5 text-primary">
<SparklesIcon class="size-3" />{digest.knowledge_created.length}
</span>
{/if}
</span>
</button>
{#if open}
<div class="flex flex-col gap-2 px-3 pb-3 text-xs">
<div class="flex flex-wrap gap-1">
{#each Object.entries(digest.by_status) as [status, count]}
<Badge variant={statusVariant(status)}>{status} × {count}</Badge>
{/each}
</div>
{#if digest.knowledge_created.length}
<div>
<div class="mb-1 flex items-center gap-1 text-primary">
<SparklesIcon class="size-3" />Learned this session
</div>
<ul class="list-inside list-disc text-muted-foreground">
{#each digest.knowledge_created as title}
<li>{title}</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</div>
{/if}