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}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { startWorkspace } from '$lib/stores/workspace'
|
||||
import GoalHeader from './GoalHeader.svelte'
|
||||
import PlanProgress from './PlanProgress.svelte'
|
||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||
import SessionGraph from './SessionGraph.svelte'
|
||||
import SessionDigest from './SessionDigest.svelte'
|
||||
@@ -20,7 +19,6 @@
|
||||
-->
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<GoalHeader />
|
||||
<PlanProgress />
|
||||
<OperatorQuestion />
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionGraph />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import * as Collapsible from '$lib/components/ui/collapsible'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
@@ -16,96 +15,73 @@
|
||||
const inlineCount = $derived(tools.length - bodyTools.length)
|
||||
|
||||
$effect(() => {
|
||||
if (active && !wasActive) {
|
||||
open = true
|
||||
}
|
||||
if (!active && wasActive) {
|
||||
open = false
|
||||
}
|
||||
if (active && !wasActive) open = true
|
||||
if (!active && wasActive) open = false
|
||||
wasActive = active
|
||||
})
|
||||
|
||||
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
|
||||
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
|
||||
const names = $derived(bodyTools.map((t) => t.name).join(', '))
|
||||
const total = $derived(bodyTools.length)
|
||||
|
||||
const runningTool = $derived(
|
||||
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
|
||||
)
|
||||
|
||||
const ariaLabel = $derived(
|
||||
doneCount === bodyTools.length
|
||||
? `${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} completed`
|
||||
: `${doneCount}/${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} done`
|
||||
)
|
||||
|
||||
function toolSummary(args: unknown): string {
|
||||
if (!args || typeof args !== 'object') return ''
|
||||
return Object.entries(args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join(' ')
|
||||
.slice(0, 80)
|
||||
function toolLabel(t: ToolCallResult): 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' && firstArg.length < 50) return `${t.name} ${firstArg}`
|
||||
return t.name
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if bodyTools.length}
|
||||
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
|
||||
{#if active && doneCount < bodyTools.length}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
{:else if hasError}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
{:else}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
{#if active && doneCount < bodyTools.length}
|
||||
<span class="font-medium">{doneCount}/{bodyTools.length}</span>
|
||||
{#if runningTool}
|
||||
<span class="max-w-48 truncate font-mono text-muted-foreground">
|
||||
{runningTool.name}
|
||||
<span class="animate-pulse">…</span>
|
||||
</span>
|
||||
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded border bg-card/50 text-xs">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-1.5 px-2 py-1 hover:bg-muted/30">
|
||||
<span class="shrink-0">
|
||||
{#if active && doneCount < total}
|
||||
<LoaderCircleIcon class="size-3 animate-spin text-primary" aria-hidden="true" />
|
||||
{:else if hasError}
|
||||
<XIcon class="size-3 text-destructive" aria-hidden="true" />
|
||||
{:else}
|
||||
<span class="animate-pulse text-muted-foreground">working…</span>
|
||||
<CheckIcon class="size-3 text-muted-foreground" aria-hidden="true" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="text-muted-foreground">
|
||||
{#if active && doneCount < total}
|
||||
{doneCount}/{total}
|
||||
{:else}
|
||||
{total} tool{total === 1 ? '' : 's'}
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="font-medium">{bodyTools.length} tool{bodyTools.length === 1 ? '' : 's'}</span>
|
||||
{#if inlineCount > 0}
|
||||
<span class="text-muted-foreground">· {inlineCount} card{inlineCount === 1 ? '' : 's'} shown</span>
|
||||
<span> · {inlineCount} inline</span>
|
||||
{/if}
|
||||
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
|
||||
</span>
|
||||
|
||||
{#if active && runningTool}
|
||||
<span class="font-mono truncate">{toolLabel(runningTool)}<span class="animate-pulse">…</span></span>
|
||||
{/if}
|
||||
|
||||
<ChevronDownIcon
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 ml-auto {open ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
|
||||
<div class="flex flex-col divide-y border-t" role="list" aria-label={ariaLabel}>
|
||||
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1">
|
||||
<div class="flex flex-col divide-y border-t px-2 py-1">
|
||||
{#each bodyTools as tool (tool.id)}
|
||||
<div class="p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tool.type === 'tool_result' && tool.error}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
{:else if tool.type === 'tool_result'}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
{:else}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="font-mono font-medium">{tool.name}</span>
|
||||
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
||||
</div>
|
||||
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
|
||||
{#if tool.args}
|
||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
|
||||
{/if}
|
||||
{#if tool.type === 'tool_result'}
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 py-0.5">
|
||||
{#if tool.type === 'tool_result' && tool.error}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
{:else if tool.type === 'tool_result'}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
{:else}
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
||||
{/if}
|
||||
<span class="font-mono text-[11px] truncate">{toolLabel(tool)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { writable, derived, get } from 'svelte/store'
|
||||
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
||||
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||
|
||||
@@ -81,6 +81,36 @@ export function addChatError(message: string, action?: string) {
|
||||
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action }])
|
||||
}
|
||||
|
||||
// ToolTimelineEntry — one tool call from the chat transcript, flattened for
|
||||
// the sidebar activity timeline. Derived from messages in real time.
|
||||
export interface ToolTimelineEntry {
|
||||
id: string
|
||||
name: string
|
||||
args?: any
|
||||
result?: any
|
||||
error?: string
|
||||
type: 'tool_use' | 'tool_result'
|
||||
msgIndex: number // which message this tool belongs to
|
||||
}
|
||||
|
||||
export const toolTimeline = derived(messages, ($msgs) => {
|
||||
const entries: ToolTimelineEntry[] = []
|
||||
for (let i = 0; i < $msgs.length; i++) {
|
||||
for (const t of $msgs[i].tools) {
|
||||
entries.push({
|
||||
id: t.id ?? crypto.randomUUID(),
|
||||
name: t.name,
|
||||
args: t.args,
|
||||
result: t.result,
|
||||
error: t.error,
|
||||
type: t.type,
|
||||
msgIndex: i
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
// Per-session controller tracking. Multiple tasks can stream concurrently
|
||||
// (see sendMessage's session guard above this used to be a single global
|
||||
// `activeController`, which meant cancelStream()/newChat() always aborted
|
||||
|
||||
Reference in New Issue
Block a user