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>
This commit is contained in:
2026-07-16 08:22:08 +02:00
parent 3d99282897
commit 6a8efd22bb
16 changed files with 372 additions and 217 deletions

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import Spinner from './Spinner.svelte'
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import TargetIcon from '@lucide/svelte/icons/target'
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'
@@ -22,7 +22,7 @@
function typeIcon(type: ActivityEntry['type']) {
switch (type) {
case 'goal': return TargetIcon
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':
@@ -36,29 +36,55 @@
}
function statusColor(status: ActivityEntry['status']) {
if (status === 'running') return 'text-primary'
if (status === 'failed') return 'text-destructive'
return 'text-success'
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-2 px-3 py-6 text-center">
<div class="flex items-center gap-0.5">
<div class="size-1 rounded-full bg-muted-foreground/20 animate-pulse" style="animation-delay:0s" />
<div class="size-1 rounded-full bg-muted-foreground/30 animate-pulse" style="animation-delay:0.15s" />
<div class="size-1 rounded-full bg-muted-foreground/20 animate-pulse" style="animation-delay:0.3s" />
</div>
<p class="text-xs text-muted-foreground">Waiting for activity…</p>
<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 hasDetail = !!entry.detail}
{@const icon = typeIcon(entry.type)}
{@const isOpen = expanded.has(entry.id)}
{@const time = formatTime(entry.timestamp)}
<div class="relative">
<!-- connector line -->
{#if !isLast}
@@ -66,39 +92,56 @@
{/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 {hasDetail ? 'cursor-pointer' : 'cursor-default'}"
onclick={() => hasDetail && toggle(entry.id)}
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'}
<LoaderCircleIcon class="size-3.5 animate-spin" />
<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}
<CircleCheckIcon class="size-3" />
<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>
{#if hasDetail}
<span class="mt-0.5 shrink-0 text-muted-foreground">
{#if expanded.has(entry.id)}
<ChevronDownIcon class="size-3" />
{:else}
<ChevronRightIcon class="size-3" />
{/if}
</span>
{/if}
<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 (collapsed) -->
{#if hasDetail && expanded.has(entry.id)}
<div class="pl-8 pr-3 pb-1">
<pre class="whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[10px] text-muted-foreground">{entry.detail}</pre>
<!-- 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>