scroll fixes + activity bar position
- Activity bar moved to bottom of message list (before messagesEnd) - Smart scroll: auto-scroll only during streaming or when near bottom - Scrolling up pauses auto-scroll until next send - Removed duplicate $effect block - Plan: tool timeline in sidebar (plans/2026-07-14-tool-timeline-sidebar.md)
This commit is contained in:
92
plans/2026-07-14-tool-timeline-sidebar.md
Normal file
92
plans/2026-07-14-tool-timeline-sidebar.md
Normal file
@@ -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.
|
||||||
@@ -20,6 +20,31 @@
|
|||||||
|
|
||||||
let input = $state('')
|
let input = $state('')
|
||||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||||
|
let scrolledUp = $state(false)
|
||||||
|
let container = $state<HTMLDivElement | null>(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
|
const MAX_INLINE_CARDS = 5
|
||||||
|
|
||||||
@@ -63,12 +88,6 @@
|
|||||||
window.addEventListener('pointerup', up)
|
window.addEventListener('pointerup', up)
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void $messages
|
|
||||||
void $streaming
|
|
||||||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
|
||||||
})
|
|
||||||
|
|
||||||
function render(text: string): string {
|
function render(text: string): string {
|
||||||
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
|
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
|
||||||
}
|
}
|
||||||
@@ -77,6 +96,7 @@
|
|||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if (!text || $streaming) return
|
if (!text || $streaming) return
|
||||||
input = ''
|
input = ''
|
||||||
|
scrolledUp = false
|
||||||
sendMessage(text)
|
sendMessage(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +133,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||||
{#if $messages.length === 0}
|
{#if $messages.length === 0}
|
||||||
<div class="flex flex-col items-center gap-6 pt-24 text-center">
|
<div class="flex flex-col items-center gap-6 pt-24 text-center">
|
||||||
@@ -131,30 +151,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $currentSession && $messages.length > 0}
|
|
||||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
{#if $streaming}
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
|
||||||
<span>Agent is responding…</span>
|
|
||||||
{:else if liveStatus === 'executing'}
|
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-warning" />
|
|
||||||
<span>Working — {statusLabel(liveStatus)}</span>
|
|
||||||
<Button size="xs" variant="outline" class="ml-auto h-6 text-[11px]" onclick={async () => {
|
|
||||||
if ($currentSession) { await resumeSession($currentSession) }
|
|
||||||
}}>Continue</Button>
|
|
||||||
{:else if liveStatus === 'awaiting_input'}
|
|
||||||
<span class="text-warning">Waiting for your answer</span>
|
|
||||||
{:else if liveStatus}
|
|
||||||
<span>Status: {statusLabel(liveStatus)}</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">Session ended</span>
|
|
||||||
{/if}
|
|
||||||
{#if $currentTask?.goal}
|
|
||||||
<span class="text-muted-foreground">· {$currentTask.goal.slice(0, 60)}{$currentTask.goal.length > 60 ? '…' : ''}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each $messages as msg, i (msg.id)}
|
{#each $messages as msg, i (msg.id)}
|
||||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||||
{#if msg.role === 'user'}
|
{#if msg.role === 'user'}
|
||||||
@@ -191,6 +187,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if $currentSession && $messages.length > 0}
|
||||||
|
<div class="flex items-center gap-2 text-xs text-muted-foreground py-1">
|
||||||
|
{#if $streaming}
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
||||||
|
<span>Agent is responding…</span>
|
||||||
|
{:else if liveStatus === 'executing'}
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-warning" />
|
||||||
|
<span>Working — {statusLabel(liveStatus)}</span>
|
||||||
|
<Button size="xs" variant="outline" class="ml-auto h-6 text-[11px]" onclick={async () => {
|
||||||
|
if ($currentSession) { await resumeSession($currentSession) }
|
||||||
|
}}>Continue</Button>
|
||||||
|
{:else if liveStatus === 'awaiting_input'}
|
||||||
|
<span class="text-warning">Waiting for your answer</span>
|
||||||
|
{:else if liveStatus}
|
||||||
|
<span>Status: {statusLabel(liveStatus)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if $currentTask?.goal}
|
||||||
|
<span class="text-muted-foreground">· {$currentTask.goal.slice(0, 60)}{$currentTask.goal.length > 60 ? '…' : ''}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div bind:this={messagesEnd}></div>
|
<div bind:this={messagesEnd}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user