Files
oikos/web/src/lib/components/ActivityTimeline.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

153 lines
7.0 KiB
Svelte

<script lang="ts">
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import Spinner from './Spinner.svelte'
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import MilestoneIcon from '@lucide/svelte/icons/milestone'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
import FlagIcon from '@lucide/svelte/icons/flag'
import ShieldIcon from '@lucide/svelte/icons/shield'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
let expanded = $state(new Set<string>())
function toggle(id: string) {
if (expanded.has(id)) expanded.delete(id)
else expanded.add(id)
expanded = new Set(expanded)
}
function typeIcon(type: ActivityEntry['type']) {
switch (type) {
case 'goal': return MilestoneIcon
case 'plan': return ListTodoIcon
case 'step_running': case 'step_done': case 'step_failed':
case 'tool_running': case 'tool_done': case 'tool_error':
return null // use status icon instead
case 'knowledge': return SparklesIcon
case 'complete': return FlagIcon
case 'question': return HelpCircleIcon
case 'approval': return ShieldIcon
default: return null
}
}
function statusColor(status: ActivityEntry['status']) {
if (status === 'failed') return 'text-destructive'
return 'text-primary'
}
// Tool results often arrive as a JSON string — pretty-print it when it
// parses, otherwise fall back to the raw text rather than hiding it.
function prettyPrint(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
function formatTime(ts: number): string | null {
if (!ts) return null
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
</script>
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto">
{#if $activityLog.length === 0}
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
<svg viewBox="0 0 64 110" class="h-20 w-auto text-muted-foreground/40" fill="none">
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
<circle cx="32" cy="22" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="55" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
<animate attributeName="r" values="4;11;4" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="32" cy="88" r="4" fill="currentColor">
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
</circle>
</svg>
<p class="max-w-[12rem] text-xs leading-relaxed text-muted-foreground">Waiting for activity…</p>
</div>
{:else}
<div class="flex flex-col py-1">
{#each $activityLog as entry, i (entry.id)}
{@const isLast = i === $activityLog.length - 1}
{@const icon = typeIcon(entry.type)}
{@const isOpen = expanded.has(entry.id)}
{@const time = formatTime(entry.timestamp)}
<div class="relative">
<!-- connector line -->
{#if !isLast}
<div class="absolute left-[17px] top-6 bottom-0 w-px bg-border"></div>
{/if}
<button
type="button"
class="flex w-full items-start gap-2 {entry.indent ? 'pl-7' : 'px-3'} py-1.5 text-left text-xs hover:bg-muted/30 cursor-pointer"
onclick={() => toggle(entry.id)}
>
<!-- status icon -->
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
{#if entry.status === 'running'}
<Spinner class="size-3.5" />
{:else if entry.status === 'failed'}
<CircleXIcon class="size-3.5" />
{:else if icon}
<svelte:component this={icon} class="size-3" />
{:else}
<CircleDotIcon class="size-3" />
{/if}
</span>
<!-- description -->
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
{entry.description}
</span>
<span class="mt-0.5 shrink-0 text-muted-foreground">
{#if isOpen}
<ChevronDownIcon class="size-3" />
{:else}
<ChevronRightIcon class="size-3" />
{/if}
</span>
</button>
<!-- detail -->
{#if isOpen}
<div class="flex flex-col gap-1.5 pl-8 pr-3 pb-2">
<div class="flex items-center gap-2 text-[10px] text-muted-foreground">
<span class="capitalize">{entry.status}</span>
{#if time}<span aria-hidden="true">·</span><span>{time}</span>{/if}
{#if entry.toolName}<span aria-hidden="true">·</span><code class="font-mono">{entry.toolName}</code>{/if}
</div>
{#if entry.args}
<div>
<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">Called with</p>
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.args)}</pre>
</div>
{/if}
{#if entry.detail}
<div>
{#if entry.args}<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">{entry.status === 'failed' ? 'Error' : 'Result'}</p>{/if}
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.detail)}</pre>
</div>
{/if}
{#if !entry.args && !entry.detail}
<p class="text-[10px] text-muted-foreground/70">No further detail for this step.</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>