From b414722fc72f395d4c66106f765c636ff70ac21a Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 14 Jul 2026 12:19:26 +0200 Subject: [PATCH] sidebar activity timeline replaces tool display in chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- VERSION | 2 +- plans/2026-07-14-activity-timeline.md | 133 +++++++++++++++ .../lib/components/ActivityTimeline.svelte | 111 ++++++++++++ web/src/lib/components/AgentIndicator.svelte | 34 +--- .../lib/components/TaskContextPanel.svelte | 16 +- web/src/lib/stores/chat.ts | 159 +++++++++++++++--- web/src/pages/Chat.svelte | 15 +- 7 files changed, 394 insertions(+), 76 deletions(-) create mode 100644 plans/2026-07-14-activity-timeline.md create mode 100644 web/src/lib/components/ActivityTimeline.svelte diff --git a/VERSION b/VERSION index 1c09c74..42045ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.3 +0.3.4 diff --git a/plans/2026-07-14-activity-timeline.md b/plans/2026-07-14-activity-timeline.md new file mode 100644 index 0000000..48734bb --- /dev/null +++ b/plans/2026-07-14-activity-timeline.md @@ -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 + +- `` rendered in chat +- `` at bottom + +### 4. Update TaskContextPanel + +Replace PlanProgress + SessionDigest with ActivityTimeline. diff --git a/web/src/lib/components/ActivityTimeline.svelte b/web/src/lib/components/ActivityTimeline.svelte new file mode 100644 index 0000000..990638e --- /dev/null +++ b/web/src/lib/components/ActivityTimeline.svelte @@ -0,0 +1,111 @@ + + +
+
+ Activity + {#if $streaming} + + + live + + {/if} +
+
+ {#if $activityLog.length === 0} +
+ Waiting for agent activity… +
+ {:else} +
+ {#each $activityLog as entry, i (entry.id)} + {@const isLast = i === $activityLog.length - 1} + {@const hasDetail = !!entry.detail} + {@const icon = typeIcon(entry.type)} +
+ + {#if !isLast} +
+ {/if} + + + {#if hasDetail && expanded.has(entry.id)} +
+
{entry.detail}
+
+ {/if} +
+ {/each} +
+ {/if} +
+
diff --git a/web/src/lib/components/AgentIndicator.svelte b/web/src/lib/components/AgentIndicator.svelte index a02788d..4358b8c 100644 --- a/web/src/lib/components/AgentIndicator.svelte +++ b/web/src/lib/components/AgentIndicator.svelte @@ -1,10 +1,10 @@ diff --git a/web/src/lib/components/TaskContextPanel.svelte b/web/src/lib/components/TaskContextPanel.svelte index 626c199..a905aa7 100644 --- a/web/src/lib/components/TaskContextPanel.svelte +++ b/web/src/lib/components/TaskContextPanel.svelte @@ -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()) -
+
- +
+ +
diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts index ce6ac8a..2adc917 100644 --- a/web/src/lib/stores/chat.ts +++ b/web/src/lib/stores/chat.ts @@ -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 diff --git a/web/src/pages/Chat.svelte b/web/src/pages/Chat.svelte index 84c8394..7a843ec 100644 --- a/web/src/pages/Chat.svelte +++ b/web/src/pages/Chat.svelte @@ -1,9 +1,8 @@