unified agent indicator at end of conversation
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 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:
2026-07-14 11:59:04 +02:00
parent 04677fdf4b
commit 9f40f19f25
5 changed files with 197 additions and 36 deletions

View 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}

View File

@@ -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>