diff --git a/VERSION b/VERSION index 0d91a54..9e11b32 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.3.1 diff --git a/plans/2026-07-14-tool-timeline-sidebar.md b/plans/2026-07-14-tool-timeline-sidebar.md new file mode 100644 index 0000000..3ead478 --- /dev/null +++ b/plans/2026-07-14-tool-timeline-sidebar.md @@ -0,0 +1,92 @@ +# 2026-07-14 — Tool timeline in sidebar + session analysis + +**Status:** Planned + +## Session analysis: `1d614a1f` (2026-07-14T09:21) + +"Audit all homelab hosts and active LXCs for pending apt updates" + +### What went well +- `list_lxcs(state="active")` worked — only returned live LXCs (our Phase 5 fix) +- Agent discovered that `apt-get update` on host targets gets classified as `config_mutation` (modifies apt cache) — all 9 needed approval, user bulk-approved +- 162 tool calls across 4 turns: 30 + 78 + 0 (auto-resume fail) + 54 = legitimate fleet audit +- Our Phase 1 fix worked: 3 auto-resume failures persisted "task is paused" notes instead of auto-failing + +### What failed +- **Agent never called `complete_task`.** Session status stuck at `"planning"` despite: + - `set_goal` called in first turn + - 162 tool calls executed + - Final message has full audit text + - But `propose_plan` never cleared the gate — the agent entered planning and never left +- The 3 auto-resume failures filled the transcript with `[System: auto-resume failed…]` noise + +### Root cause: `apt-get update` triggers `config_mutation` classification +- The command classifier correctly treats `apt-get update` as state-changing (it writes to the apt cache) +- But the agent just wanted to READ package lists. The audit batch of 9 `apt-get update` calls all needed approval +- Lesson: `apt-get update` should be in a separate audit/update pair where the audit phase uses a read-only inspection command (e.g. `apt list --upgradable` doesn't mutate cache) + +### Tool usage pattern +- 30 tool calls in turn 1 (set_goal, propose_plan, list_lxcs, run × 9 for apt update on hosts, update_plan_step × 8, ask_operator) +- 78 tool calls in turn 3 (run × 25+ for apt audits, get_execution_status × 10+, update_plan_step × 10) +- 54 tool calls in turn 7 (final result synthesis) + +These 162 tool calls are ALL rendered inline in chat today. The operator sees a massive wall of collapsed ToolCallGroup entries. + +--- + +## Plan + +### 1. Scroll fixes (DONE above) +- Activity bar moved to bottom of messages (before messagesEnd) +- Smart scroll: auto-scroll only during streaming OR when user is near bottom +- Scrolling up pauses auto-scroll until user sends a new message + +### 2. Tool timeline in sidebar + +**Goal:** Decouple tool execution noise from conversation. Chat shows agent's +thinking; sidebar shows what it's doing. + +#### 2.1 — Chat: compact tool indicator +Replace the full ToolCallGroup in chat with a single compact line: +``` +[N tools used — view in Activity] +``` +- Clicking it opens/highlights the sidebar timeline +- Pending approvals still show inline in chat (InlineApproval stays) +- Inline tool renderers (entity cards, health summary, etc.) stay — they're + informational, not noise + +#### 2.2 — Sidebar: live tool timeline +The "This session" section (SessionDigest) becomes a live tool timeline: +- Each agent turn gets a timestamp header +- Within each turn: tool calls shown as a compact list with status icons + (running spinner / done check / failed X) +- Tool names are the same compact format from ToolCallGroup +- Results stay collapsible (click to expand) +- Auto-scrolls to latest, but doesn't force-follow if user is reading history +- UPDATEs live (no reload needed) — same polling mechanism as SessionDigest + +#### 2.3 — Sidebar: merge plan steps +PlanProgress and SessionDigest merge into one "Activity" panel: +- Top: plan steps with progress bar (from PlanProgress) +- Middle: live tool timeline (from SessionDigest) +- Bottom: knowledge created this session (from SessionDigest) + +### 3. `complete_task` enforcement (session finding) + +The agent called `set_goal` and then the task entered `planning` status. +But the flow is: +- `set_goal` → status changes to `planning` +- `propose_plan` → status changes to `executing` + +The agent called `set_goal` but never `propose_plan` that clears `planning`. +Looking at the code: `setGoal` in store.go sets `status = 'planning'`, and +`proposePlan` sets `status = 'executing'`. So the agent must have called +`set_goal` but the subsequent `propose_plan` failed or the agent skipped it. + +**Fix:** In `setGoal`, if the agent has enough information to propose a plan, +auto-transition to `executing` when the first tool call is made (not when +`set_goal` is called — that's too early). The status `planning` should only +stick if the agent explicitly calls `ask_operator` for more info. Otherwise, +status `planning` is indistinguishable from `active` — it just means the +agent never formalized the transition. diff --git a/web/src/pages/Chat.svelte b/web/src/pages/Chat.svelte index 9a49b3d..96cfba6 100644 --- a/web/src/pages/Chat.svelte +++ b/web/src/pages/Chat.svelte @@ -20,6 +20,31 @@ let input = $state('') let messagesEnd = $state(null) + let scrolledUp = $state(false) + let container = $state(null) + + function isNearBottom(): boolean { + if (!container) return true + const { scrollTop, scrollHeight, clientHeight } = container + return scrollHeight - scrollTop - clientHeight < 80 + } + + function onScroll() { + scrolledUp = !isNearBottom() + } + + // Auto-scroll to bottom on new messages — unless user scrolled up to read. + $effect(() => { + void $messages + if ($streaming || !scrolledUp) { + setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50) + } + }) + + // Reset scroll lock when user sends a message. + function submitFollows() { + scrolledUp = false + } const MAX_INLINE_CARDS = 5 @@ -63,12 +88,6 @@ window.addEventListener('pointerup', up) } - $effect(() => { - void $messages - void $streaming - setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50) - }) - function render(text: string): string { return DOMPurify.sanitize(marked.parse(text, { async: false }) as string) } @@ -77,6 +96,7 @@ const text = input.trim() if (!text || $streaming) return input = '' + scrolledUp = false sendMessage(text) } @@ -113,7 +133,7 @@ {/if}
-
+
{#if $messages.length === 0}
@@ -131,30 +151,6 @@
{/if} - {#if $currentSession && $messages.length > 0} -
- {#if $streaming} - - Agent is responding… - {:else if liveStatus === 'executing'} - - Working — {statusLabel(liveStatus)} - - {:else if liveStatus === 'awaiting_input'} - Waiting for your answer - {:else if liveStatus} - Status: {statusLabel(liveStatus)} - {:else} - Session ended - {/if} - {#if $currentTask?.goal} - · {$currentTask.goal.slice(0, 60)}{$currentTask.goal.length > 60 ? '…' : ''} - {/if} -
- {/if} - {#each $messages as msg, i (msg.id)}
{#if msg.role === 'user'} @@ -191,6 +187,27 @@ {/if}
{/each} + {#if $currentSession && $messages.length > 0} +
+ {#if $streaming} + + Agent is responding… + {:else if liveStatus === 'executing'} + + Working — {statusLabel(liveStatus)} + + {:else if liveStatus === 'awaiting_input'} + Waiting for your answer + {:else if liveStatus} + Status: {statusLabel(liveStatus)} + {/if} + {#if $currentTask?.goal} + · {$currentTask.goal.slice(0, 60)}{$currentTask.goal.length > 60 ? '…' : ''} + {/if} +
+ {/if}