- 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
216 lines
8.6 KiB
Svelte
216 lines
8.6 KiB
Svelte
<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}
|