- 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>
153 lines
7.0 KiB
Svelte
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>
|