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
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
|
||||
import { currentSession, streaming } from '$lib/stores/chat'
|
||||
import { currentTask } from '$lib/stores/workspace'
|
||||
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)
|
||||
// Keyed on session id AND status: a task that completes mid-view (via
|
||||
// resumeSession running server-side, with $streaming never true here) must
|
||||
// still refetch once outcome/summary land, not just on session switch.
|
||||
let openTools = $state(false)
|
||||
let loadedKey = $state<string | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = $state(null)
|
||||
@@ -28,7 +29,6 @@
|
||||
loadedKey = key
|
||||
fetchSessionDigest(sid).then((d) => (digest = d))
|
||||
}
|
||||
// Poll every 10s while the session is active.
|
||||
if (status !== 'done' && status !== 'failed') {
|
||||
if (!pollTimer) pollTimer = setInterval(() => fetchSessionDigest(sid).then((d) => (digest = d)), 10000)
|
||||
} else {
|
||||
@@ -45,8 +45,42 @@
|
||||
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'
|
||||
@@ -64,6 +98,76 @@
|
||||
</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
|
||||
@@ -76,7 +180,7 @@
|
||||
This session
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||||
{digest.total_executions} action{digest.total_executions === 1 ? '' : 's'}
|
||||
{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}
|
||||
@@ -86,42 +190,19 @@
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="flex flex-col gap-3 px-3 pb-3 text-xs">
|
||||
<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.entities_touched.length}
|
||||
<div>
|
||||
<div class="mb-1 text-muted-foreground">Entities touched</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each digest.entities_touched as target}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{target}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each digest.executions as ex}
|
||||
<div class="flex items-start justify-between gap-2 rounded border px-2 py-1">
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-[11px] text-muted-foreground">{ex.target}</div>
|
||||
<div class="truncate">{ex.summary || ex.verb}</div>
|
||||
</div>
|
||||
<Badge variant={statusVariant(ex.status)} class="shrink-0">{ex.status}</Badge>
|
||||
</div>
|
||||
{/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">
|
||||
<ul class="list-inside list-disc text-muted-foreground">
|
||||
{#each digest.knowledge_created as title}
|
||||
<li>{title}</li>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user