unified agent indicator at end of conversation
- New AgentIndicator component: replaces 3 separate indicators (loading dots, ToolCallGroup summary, activity bar) with one - Positioned as last item in message list — scrolls naturally - Shows current tool action: 'Researching lxc:nfs-export…' etc - Spinner during work, check on completion, X on error - Fades out 3s after turn completes - Activity bar, loading dots, statusLabel removed from Chat - Continue button moved to sidebar session panel
This commit is contained in:
118
plans/2026-07-14-unified-agent-indicator.md
Normal file
118
plans/2026-07-14-unified-agent-indicator.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 2026-07-14 — Unified agent activity indicator
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Current state — three separate indicators
|
||||
|
||||
| Component | Location | Shows |
|
||||
|---|---|---|
|
||||
| Loading dots (Chat.svelte:146) | Inline in assistant bubble | 3 bouncing dots when no text/tools yet |
|
||||
| ToolCallGroup trigger row | Inline in assistant bubble | "12 tools" with spinner |
|
||||
| Activity bar | Bottom of message list | "Agent is responding…" / "Working" / Continue button |
|
||||
|
||||
All three overlap. The operator sees dots → then a tool count → then the activity bar — three different visual styles for the same thing: "the agent is working."
|
||||
|
||||
## Target: single indicator appended to conversation
|
||||
|
||||
One row, always the last item in the message list, that replaces the loading dots, ToolCallGroup summary, and activity bar. Think of it like a system message appended at the end of the conversation.
|
||||
|
||||
### Behavior
|
||||
|
||||
```
|
||||
User: "audit fleet"
|
||||
Assistant: "I'll check all hosts. Here's the plan..." ← full message bubble
|
||||
|
||||
┌─ Agent is auditing… ───────────────────┐
|
||||
│ ◉ apt update on lxc:jellyfin │ ← spinner + current action
|
||||
└────────────────────────────────────────┘
|
||||
|
||||
... agent finishes ...
|
||||
|
||||
Assistant: "Done. 19 LXCs have pending updates." ← next message bubble
|
||||
```
|
||||
|
||||
The indicator:
|
||||
- **Appears** when the agent starts working (first `tool_use` event or `streaming=true`)
|
||||
- **Updates** its description with the current tool name in flight
|
||||
- **Collapses/disappears** when the turn ends (`done` event or `streaming=false`)
|
||||
- If there were tools, shows a brief completion summary for 3 seconds then fades
|
||||
- During auto-continuation (polling picks up new messages), reappears if the agent did tool calls
|
||||
|
||||
### States
|
||||
|
||||
| State | Icon | Description |
|
||||
|---|---|---|
|
||||
| Thinking | ◉ pulse | "Agent is thinking…" |
|
||||
| Planning | ◉ pulse | "Building plan…" |
|
||||
| Researching | ◉ pulse | "Researching <entity>…" |
|
||||
| Executing | ◉ spinner | "<tool_name> <target>…" |
|
||||
| Done | ✓ | Fades out after 3s |
|
||||
|
||||
### Data source
|
||||
|
||||
The description comes from the most recent `tool_use` event's name + args. If no tools yet, show generic "thinking" message. The derived `toolTimeline` store already has this data.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. New component: `AgentIndicator.svelte`
|
||||
|
||||
**Props:** `active: boolean`, `lastTool: ToolCallResult | null`, `toolCount: number`
|
||||
|
||||
Renders a single compact row:
|
||||
```html
|
||||
<div class="activity-indicator">
|
||||
<LoaderCircle class="animate-spin" /> <!-- or CheckIcon when done -->
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
`label` is derived:
|
||||
```ts
|
||||
const label = $derived.by(() => {
|
||||
if (!active) return ''
|
||||
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 'run': return `${args.purpose ?? 'Running command…'}`
|
||||
case 'list_lxcs': return 'Listing containers…'
|
||||
case 'update_plan_step': return 'Updating progress…'
|
||||
case 'upsert_knowledge': return 'Recording knowledge…'
|
||||
case 'complete_task': return 'Wrapping up…'
|
||||
default: return `${lastTool.name}…`
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Chat.svelte changes
|
||||
|
||||
- **Remove** activity bar from bottom of messages
|
||||
- **Replace** the 3 bouncing dots `{#if msg.tools.length === 0}` with nothing (the indicator covers this)
|
||||
- **Add** `<AgentIndicator>` after the `{#each}` loop, before `messagesEnd`
|
||||
- The indicator shows when `$streaming || liveStatus === 'executing'`
|
||||
- Pass `lastTool` from `$toolTimeline` — the last tool_use entry
|
||||
|
||||
### 3. Remove activity bar code
|
||||
|
||||
Delete the `{#if $currentSession && $messages.length > 0}` block at the bottom (already moved once, now deleted entirely — replaced by AgentIndicator).
|
||||
|
||||
### 4. Remove loading dots
|
||||
|
||||
In Chat.svelte, remove the 3 bouncing dots block:
|
||||
```svelte
|
||||
{:else if msg.tools.length === 0}
|
||||
<div class="flex items-center gap-1.5 py-1 text-sm text-muted-foreground">
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-current ...">...</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
- Send "status" → indicator appears "Agent is thinking…" → agent responds → indicator fades
|
||||
- Send "check updates on jellyfin" → indicator shows "Researching…" → "Listing containers…" → "Running apt list…" → fades
|
||||
- Auto-continuation fires → indicator reappears with current tool → fades when done
|
||||
- Scroll up during agent work → indicator stays at bottom of message list (it's just a message)
|
||||
- Error during agent work → indicator shows "Error: …" with X icon
|
||||
@@ -14,7 +14,9 @@ went sideways, open an investigation.
|
||||
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
||||
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
|
||||
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
||||
| 2026-07-14 | [Session reliability & UX audit](2026-07-14-session-reliability-and-ux-audit.md) | Planned — just audited, not started |
|
||||
| 2026-07-14 | [Session reliability & UX audit](2026-07-14-session-reliability-and-ux-audit.md) | Done — all 21 fixes deployed |
|
||||
| 2026-07-14 | [Tool timeline in sidebar](2026-07-14-tool-timeline-sidebar.md) | Done — deployed v0.3.2 |
|
||||
| 2026-07-14 | [Unified agent activity indicator](2026-07-14-unified-agent-indicator.md) | Done — deployed v0.3.3 |
|
||||
|
||||
## Done
|
||||
|
||||
|
||||
68
web/src/lib/components/AgentIndicator.svelte
Normal file
68
web/src/lib/components/AgentIndicator.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } 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 done = $state(false)
|
||||
let wasActive = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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}…`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if active || done || error}
|
||||
<div class="flex items-center gap-2 py-2 text-xs {error ? 'text-destructive' : done ? 'text-success' : 'text-muted-foreground'}" class:opacity-50={done} class:transition-opacity>
|
||||
<span class="shrink-0">
|
||||
{#if error}
|
||||
<XIcon class="size-3" />
|
||||
{:else if done}
|
||||
<CheckIcon class="size-3" />
|
||||
{:else}
|
||||
<LoaderCircleIcon class="size-3 animate-spin text-primary" />
|
||||
{/if}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,18 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
|
||||
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError, toolTimeline } from '$lib/stores/chat'
|
||||
import { currentTask } from '$lib/stores/workspace'
|
||||
import { resumeSession } from '$lib/api'
|
||||
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'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
@@ -119,10 +118,6 @@
|
||||
sendMessage(q)
|
||||
}
|
||||
|
||||
function statusLabel(s: string): string {
|
||||
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
const liveStatus = $derived($currentTask?.status ?? ($currentSession ? 'active' : null))
|
||||
</script>
|
||||
|
||||
@@ -173,12 +168,6 @@
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html render(msg.text)}
|
||||
</div>
|
||||
{:else if msg.tools.length === 0}
|
||||
<div class="flex items-center gap-1.5 py-1 text-sm text-muted-foreground">
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]"></span>
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]"></span>
|
||||
<span class="size-1.5 animate-bounce rounded-full bg-current"></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.pendingApprovals.length > 0}
|
||||
<InlineApproval approvals={msg.pendingApprovals} />
|
||||
@@ -187,27 +176,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/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}
|
||||
<AgentIndicator
|
||||
active={$streaming || liveStatus === 'executing'}
|
||||
lastTool={$toolTimeline.filter((t) => t.type === 'tool_use').at(-1) ?? null}
|
||||
error={$error}
|
||||
/>
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user