Files
oikos/web/src/lib/components/SessionDigest.svelte
dtoro 04677fdf4b
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
tool timeline in sidebar + compact chat tools + scroll fixes
- SessionDigest now includes live tool timeline, plan steps, knowledge
- ToolCallGroup compact: single-line with collapsible names only (no JSON)
- Activity bar moved to bottom of messages, smart scroll respects user position
- setGoal now sets status=executing (removed stuck planning state)
- PlanProgress merged into SessionDigest, removed from TaskContextPanel
- New toolTimeline derived store in chat.ts
2026-07-14 11:45:36 +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 LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
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">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin 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'}
<LoaderCircleIcon class="size-3 animate-spin 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}
<LoaderCircleIcon class="size-3 animate-spin 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}