tool timeline in sidebar + compact chat tools + scroll fixes
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

- 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:
2026-07-14 11:45:36 +02:00
parent cc6bcdceaa
commit 04677fdf4b
6 changed files with 191 additions and 103 deletions

View File

@@ -1 +1 @@
0.3.1
0.3.2

View File

@@ -358,13 +358,16 @@ func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID
return &id
}
// setGoal records the task's goal and moves it into planning. Emits goal.set.
// setGoal records the task's goal and moves it into executing. The `planning`
// intermediate state was removed (2026-07-14) — it was indistinguishable from
// `active` to the operator and caused sessions to appear stuck when the agent
// called set_goal but never propose_plan (observed in production).
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
if _, err := s.pool.Exec(ctx,
`UPDATE agent_sessions SET goal = $2, status = 'planning', last_active_at = now() WHERE id = $1`,
`UPDATE agent_sessions SET goal = $2, status = 'executing', last_active_at = now() WHERE id = $1`,
sessionID, goal); err != nil {
return err
}

View File

@@ -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}

View File

@@ -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 />

View File

@@ -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" />
<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 shrink-0 text-destructive" aria-hidden="true" />
<XIcon class="size-3 text-destructive" aria-hidden="true" />
{:else}
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<CheckIcon class="size-3 text-muted-foreground" 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>
<span class="text-muted-foreground">
{#if active && doneCount < total}
{doneCount}/{total}
{:else}
<span class="animate-pulse text-muted-foreground">working…</span>
{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">
<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" aria-hidden="true" />
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
{/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>
<span class="font-mono text-[11px] truncate">{toolLabel(tool)}</span>
</div>
{/each}
</div>

View File

@@ -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