sidebar activity timeline replaces tool display in chat
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

- New ActivityTimeline: unified timeline in sidebar showing all agent actions
  (goal, plan steps, tool calls, knowledge, completion) in reverse chron order
- activityLog derived store merges messages + planSteps + currentTask
- AgentIndicator stays in chat (thinking/working indicator), simplified props
- ToolCallGroup removed from chat — tools visible only in sidebar timeline
- SessionDigest replaced by ActivityTimeline
- PlanProgress restored in sidebar (conceptual steps, separate from timeline)
This commit is contained in:
2026-07-14 12:19:26 +02:00
parent cc266c238e
commit b414722fc7
7 changed files with 394 additions and 76 deletions

View File

@@ -1 +1 @@
0.3.3
0.3.4

View File

@@ -0,0 +1,133 @@
# 2026-07-14 — Unified sidebar activity timeline
**Status:** Planned
## Current state (broken)
The sidebar has three sections that appear/disappear independently:
| Section | When visible | Shows |
|---|---|---|
| Plan (top) | When `$planSteps.length > 0` | Plan step names with progress bar |
| Tool activity (middle) | When `toolCount > 0` | Compact tool list, grouped by turn |
| This session (bottom) | When `digest.total_executions > 0` | Post-hoc execution count + knowledge |
State changes cause sections to **pop in/out** as the agent moves between
planning → executing → done. The "0 tools · 0 running" counter flashes
briefly then vanishes. Tool activity appears/disappears between turns.
## Target: single unified timeline
One section, always present when a session is loaded. Every agent action
appears as an entry in reverse-chronological order (newest at top).
```
┌─ Activity ───────────────────────── ─┐
│ │
│ ✓ Task completed: "Upgraded 4 LXCs" │ ← newest
│ ◉ Running: apt upgrade on lxc:dns │
│ ✓ run: apt upgrade on lxc:gitea │ ← tool completed
│ ✓ Verified gitea: HTTP 200 │
│ ◉ Step 3/5 — Upgrade dns │ ← plan step running
│ ✓ Step 2/5 — Upgrade gitea │ ← plan step done
│ ✓ run: apt upgrade on lxc:nfs-export │
│ ◉ Step 1/5 — Upgrade nfs-export │
│ ✓ Knowledge recorded │
│ 📋 Plan set: 5 steps │ ← plan proposed
│ 🎯 Goal: Upgrade 4 low-risk LXCs │ ← goal set
│ │ ← oldest
└───────────────────────────────────────┘
```
### Entry types
| Type | Icon | Example description |
|---|---|---|
| `goal` | 🎯 | "Audit all LXCs for updates" |
| `plan` | 📋 | "Plan set: 5 steps" |
| `step_start` | ◉ spinner | "Step 2/5 — Upgrade gitea" |
| `step_done` | ✓ | "Step 2/5 — Upgrade gitea" |
| `tool_start` | ◉ spinner | "run: Upgrade nfs-export (21 pkgs)" |
| `tool_done` | ✓ | "run: 0 upgraded, 0 newly installed" |
| `tool_error` | ✗ | "run: SSH handshake failed" |
| `knowledge` | ✨ | "Recorded: How to run fleet upgrades" |
| `complete` | ✓ | "Task completed: success" |
| `question` | ❓ | "Asked: Which host for the LXC?" |
| `error` | ✗ | "Auto-resume failed: context deadline exceeded" |
### Data source
Entries come from all available sources, merged and deduplicated:
1. **`toolTimeline` store** (live tool_use/tool_result pairs)
2. **`planSteps` store** (step status transitions)
3. **Session digest API** (knowledge created, final outcome)
4. **`currentTask` store** (goal, status)
Deduplication: when a plan step links to a tool call via `execution_id`, show
them as one entry instead of two (e.g. "Step 3: Upgrade dns ◉ running" includes
the tool — don't show a separate "run: apt upgrade" entry).
### Behavior
- **Always visible** when `$currentSession` is set
- **Reverse chronological** — newest entries at top, scrolls naturally
- **Auto-expands** the entry for the currently-running tool/step
- **Collapses** completed entries to one line (expandable)
- **Polls** every 3s for live updates (same as current startPolling)
- **No flashing** — entries only change status in place (tool_start → tool_done), never removed
- **Persists** across page navigation (rehydrated from REST on load)
- **Empty state** when no session: "Open a session to see agent activity"
### What gets removed from chat
- **ToolCallGroup** — the compact tool counter. Tools live in the timeline now.
- **AgentIndicator at bottom** — partially. Keep it ONLY for the initial
"thinking" state (before any tools fire). Once the first tool fires, the
timeline is the source of truth and the chat indicator is redundant.
Actually: remove it entirely. The timeline IS the indicator.
### What stays in chat
- **Agent text responses** — the thinking, conclusions, reports
- **InlineApproval cards** — approvals need operator action, must be in chat
- **Inline tool renderers** — entity cards, health summary, etc. (informational)
- **User messages** — obviously
## Implementation
### 1. Data layer: `activityLog` derived store
Add to `chat.ts`:
```ts
export interface ActivityEntry {
id: string
type: 'goal' | 'plan' | 'step_start' | 'step_done' | 'step_failed' |
'tool_start' | 'tool_done' | 'tool_error' |
'knowledge' | 'complete' | 'question' | 'error'
description: string
detail?: string // tool result text, step detail, etc.
timestamp: number // Date.now() when created
seq?: number // plan step seq, for ordering
toolName?: string // for tool entries
status: 'running' | 'done' | 'failed'
collapsed: boolean // initial collapsed state (true for completed)
}
```
Derived reactively from `messages`, `planSteps`, `currentTask`, and session
digest data. Uses `$derived.by()` to recompute when any source changes.
### 2. New component: `ActivityTimeline.svelte`
Replaces all three sidebar sections. Renders `activityLog` entries as a
vertical timeline with connecting lines.
### 3. Remove from chat
- `<ToolCallGroup>` rendered in chat
- `<AgentIndicator>` at bottom
### 4. Update TaskContextPanel
Replace PlanProgress + SessionDigest with ActivityTimeline.

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { activityLog, type ActivityEntry, streaming } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import TargetIcon from '@lucide/svelte/icons/target'
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 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 TargetIcon
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
default: return null
}
}
function statusColor(status: ActivityEntry['status']) {
if (status === 'running') return 'text-primary'
if (status === 'failed') return 'text-destructive'
return 'text-success'
}
</script>
<div class="flex h-full flex-col">
<div class="flex items-center justify-between border-b px-3 py-2">
<span class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Activity</span>
{#if $streaming}
<span class="flex items-center gap-1 text-[11px] text-primary">
<LoaderCircleIcon class="size-3 animate-spin" />
live
</span>
{/if}
</div>
<div class="flex-1 overflow-y-auto">
{#if $activityLog.length === 0}
<div class="px-3 py-6 text-center text-xs text-muted-foreground">
Waiting for agent activity…
</div>
{:else}
<div class="flex flex-col py-1">
{#each $activityLog as entry, i (entry.id)}
{@const isLast = i === $activityLog.length - 1}
{@const hasDetail = !!entry.detail}
{@const icon = typeIcon(entry.type)}
<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 px-3 py-1.5 text-left text-xs hover:bg-muted/30 {hasDetail ? 'cursor-pointer' : 'cursor-default'}"
onclick={() => hasDetail && 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'}
<LoaderCircleIcon class="size-3.5 animate-spin" />
{:else if entry.status === 'failed'}
<CircleXIcon class="size-3.5" />
{:else if icon}
<svelte:component this={icon} class="size-3" />
{:else}
<CircleCheckIcon class="size-3" />
{/if}
</span>
<!-- description -->
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
{entry.description}
</span>
{#if hasDetail}
<span class="mt-0.5 shrink-0 text-muted-foreground">
{#if expanded.has(entry.id)}
<ChevronDownIcon class="size-3" />
{:else}
<ChevronRightIcon class="size-3" />
{/if}
</span>
{/if}
</button>
<!-- detail (collapsed) -->
{#if hasDetail && expanded.has(entry.id)}
<div class="pl-8 pr-3 pb-1">
<pre class="whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[10px] text-muted-foreground">{entry.detail}</pre>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import type { ActivityEntry } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { active = false, lastTool = null as ToolCallResult | null, error = '' }: { active?: boolean; lastTool?: ToolCallResult | null; error?: string } = $props()
let { active = false, lastActivity = null as ActivityEntry | null, error = '' }: { active?: boolean; lastActivity?: ActivityEntry | null; error?: string } = $props()
let done = $state(false)
let wasActive = $state(false)
@@ -13,7 +13,6 @@
if (active) { done = false; wasActive = true }
if (!active && wasActive) {
done = true
// Fade out after 3s
const t = setTimeout(() => { done = false; wasActive = false }, 3000)
return () => clearTimeout(t)
}
@@ -22,33 +21,8 @@
const label = $derived.by(() => {
if (error) return error
if (!active && done) return 'Done'
if (!lastTool) return 'Agent is thinking…'
const args = lastTool.args ?? {}
switch (lastTool.name) {
case 'set_goal': return 'Setting goal…'
case 'propose_plan': return 'Building plan…'
case 'search_knowledge': return `Researching: ${args.query ?? ''}`
case 'get_entity': return `Looking up ${args.slug_or_id ?? ''}`
case 'get_entity_knowledge': return `Checking prior knowledge…`
case 'get_relations': return `Checking relationships…`
case 'list_lxcs': return 'Listing containers…'
case 'list_entities': return 'Listing entities…'
case 'get_health_summary': return 'Checking fleet health…'
case 'run': {
const purpose = args.purpose ?? ''
const target = args.target ?? ''
if (purpose) return purpose
if (target) return `Running on ${target}…`
return 'Running command…'
}
case 'get_execution_status': return 'Checking execution status…'
case 'update_plan_step': return 'Updating progress…'
case 'upsert_knowledge': return 'Recording knowledge…'
case 'complete_task': return 'Wrapping up…'
case 'ping_service': return 'Checking service…'
case 'ask_operator': return 'Asking operator…'
default: return `${lastTool.name}…`
}
if (lastActivity) return lastActivity.description
return 'Agent is thinking…'
})
</script>

View File

@@ -2,26 +2,22 @@
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'
import ActivityTimeline from './ActivityTimeline.svelte'
onMount(() => startWorkspace())
</script>
<!--
The task's live control panel: goal + status, plan progress, a pinned
question when the agent needs a decision, the live entity graph (pulses what
the agent is touching, flags health changes), and the outcome/knowledge
record once the task completes. Driven by the always-on events stream
(see workspace.ts) so it keeps updating during server-side auto-continuation,
not just while a chat turn is streaming.
-->
<div class="flex h-full min-h-0 flex-col">
<GoalHeader />
<PlanProgress />
<OperatorQuestion />
<div class="min-h-0 flex-1">
<SessionGraph />
</div>
<SessionDigest />
<div class="max-h-[40%] overflow-y-auto border-t">
<ActivityTimeline />
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { writable, derived, get } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
import { planSteps, currentTask } from '$lib/stores/workspace'
import type { ChatEvent, Session, Message } from '$lib/api'
export interface PendingApproval {
@@ -81,36 +82,150 @@ 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 {
// ── Activity timeline ────────────────────────────────────────────────
export interface ActivityEntry {
id: string
name: string
args?: any
result?: any
error?: string
type: 'tool_use' | 'tool_result'
msgIndex: number // which message this tool belongs to
type: 'goal' | 'plan' | 'step_running' | 'step_done' | 'step_failed' |
'tool_running' | 'tool_done' | 'tool_error' |
'knowledge' | 'complete' | 'question' | 'error'
description: string
detail?: string
timestamp: number
toolName?: string
status: 'running' | 'done' | 'failed'
}
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
})
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => {
const entries: ActivityEntry[] = []
const now = Date.now()
// Goal
if ($task?.goal) {
entries.push({ id: 'goal', type: 'goal', description: $task.goal, timestamp: 0, status: 'done' })
}
// Plan steps
for (const s of $steps) {
if (s.status === 'pending') continue
const stepLabel = s.title || `Step ${s.seq}`
entries.push({
id: s.id,
type: s.status === 'running' ? 'step_running' : s.status === 'done' ? 'step_done' : 'step_failed',
description: `Step ${s.seq}: ${stepLabel}`,
detail: s.detail || undefined,
timestamp: s.started_at ? new Date(s.started_at).getTime() : now,
status: s.status === 'running' ? 'running' : s.status === 'done' ? 'done' : 'failed'
})
}
// Tool calls (from messages)
let entryIdx = 0
for (let mi = 0; mi < $msgs.length; mi++) {
for (const t of $msgs[mi].tools) {
const label = toolActivityLabel(t)
if (t.type === 'tool_use') {
entries.push({
id: t.id ?? `tool_${mi}_${entryIdx++}`,
type: 'tool_running',
description: label,
timestamp: now - ($msgs.length - mi) * 1000,
toolName: t.name,
status: 'running'
})
} else if (t.type === 'tool_result') {
// Find and update matching tool_use entry
const running = entries.find((e) =>
e.type === 'tool_running' && e.id === t.id && e.status === 'running'
)
if (running && t.error) {
running.type = 'tool_error'
running.status = 'failed'
running.description = `${t.name}: ${t.error.slice(0, 80)}`
} else if (running) {
running.type = 'tool_done'
running.status = 'done'
running.detail = typeof t.result === 'string'
? t.result.slice(0, 200)
: JSON.stringify(t.result ?? '').slice(0, 200)
} else {
entries.push({
id: t.id ?? `tool_${mi}_${entryIdx++}`,
type: t.error ? 'tool_error' : 'tool_done',
description: t.error ? `${t.name}: ${t.error.slice(0, 80)}` : t.name,
detail: !t.error ? (typeof t.result === 'string' ? t.result.slice(0, 200) : '') : undefined,
timestamp: now - ($msgs.length - mi) * 1000,
toolName: t.name,
status: t.error ? 'failed' : 'done'
})
}
}
}
}
// Knowledge recorded — detect from upsert_knowledge tool results
for (let mi = 0; mi < $msgs.length; mi++) {
for (const t of $msgs[mi].tools) {
if (t.type === 'tool_result' && t.name === 'upsert_knowledge' && !t.error) {
const title = t.args?.title ?? ''
entries.push({
id: `knowledge_${mi}`,
type: 'knowledge',
description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge',
timestamp: now - ($msgs.length - mi) * 1000,
status: 'done'
})
}
}
}
// Task completion
if ($task?.outcome) {
entries.push({
id: 'complete',
type: 'complete',
description: $task.summary || `Task ${$task.outcome}`,
timestamp: now,
status: $task.outcome === 'failure' ? 'failed' : 'done'
})
}
// Sort newest first
entries.sort((a, b) => b.timestamp - a.timestamp)
return entries
})
function toolActivityLabel(t: ToolCallResult): string {
const args = t.args ?? {}
switch (t.name) {
case 'set_goal': return 'Set goal'
case 'propose_plan': return 'Proposed plan'
case 'search_knowledge': return `Research: ${args.query || ''}`
case 'get_entity': return `Lookup: ${args.slug_or_id || ''}`
case 'get_entity_knowledge': return 'Check prior knowledge'
case 'get_relations': return 'Check relationships'
case 'list_lxcs': return 'List containers'
case 'list_entities': return 'List entities'
case 'get_health_summary': return 'Fleet health'
case 'get_state_snapshot': return 'State snapshot'
case 'run': {
const purpose = args.purpose as string || ''
const target = (args.target as string) || ''
if (purpose) return purpose
if (target) return `Run on ${target}`
return 'Run command'
}
case 'get_execution_status': return 'Check execution'
case 'update_plan_step': return 'Update plan'
case 'upsert_knowledge': return 'Record knowledge'
case 'complete_task': return 'Complete task'
case 'ping_service': return 'Check service'
case 'ask_operator': return 'Ask operator'
default: return t.name
}
}
// 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

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError, toolTimeline } from '$lib/stores/chat'
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError, activityLog } from '$lib/stores/chat'
import { currentTask } from '$lib/stores/workspace'
import SessionRail from '$lib/components/SessionRail.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import InlineApproval from '$lib/components/InlineApproval.svelte'
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { getToolRenderer } from '$lib/tool-renderers'
@@ -53,11 +52,6 @@
return matched.slice(0, MAX_INLINE_CARDS)
}
function getRemaining(msg: { tools: any[] }, inline: any[]): any[] {
const inlineIds = new Set(inline.map((t) => t.id))
return msg.tools.filter((t) => !inlineIds.has(t.id))
}
// Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260
const RAIL_MAX = 620
@@ -158,11 +152,6 @@
<renderer.component {tool} />
{/if}
{/each}
<ToolCallGroup
tools={msg.tools}
unmatched={getRemaining(msg, getInlineTools(msg))}
active={$streaming && i === $messages.length - 1}
/>
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
@@ -178,7 +167,7 @@
{/each}
<AgentIndicator
active={$streaming || liveStatus === 'executing'}
lastTool={$toolTimeline.filter((t) => t.type === 'tool_use').at(-1) ?? null}
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
error={$error}
/>
<div bind:this={messagesEnd}></div>