sidebar activity timeline replaces tool display in chat
- 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)
This commit is contained in:
133
plans/2026-07-14-activity-timeline.md
Normal file
133
plans/2026-07-14-activity-timeline.md
Normal file
@@ -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
|
||||||
|
|
||||||
|
- `<ToolCallGroup>` rendered in chat
|
||||||
|
- `<AgentIndicator>` at bottom
|
||||||
|
|
||||||
|
### 4. Update TaskContextPanel
|
||||||
|
|
||||||
|
Replace PlanProgress + SessionDigest with ActivityTimeline.
|
||||||
111
web/src/lib/components/ActivityTimeline.svelte
Normal file
111
web/src/lib/components/ActivityTimeline.svelte
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { activityLog, type ActivityEntry, streaming } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||||
|
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||||
|
import TargetIcon from '@lucide/svelte/icons/target'
|
||||||
|
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||||
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||||
|
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||||
|
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||||
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||||
|
|
||||||
|
let expanded = $state(new Set<string>())
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
if (expanded.has(id)) expanded.delete(id)
|
||||||
|
else expanded.add(id)
|
||||||
|
expanded = new Set(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeIcon(type: ActivityEntry['type']) {
|
||||||
|
switch (type) {
|
||||||
|
case 'goal': return TargetIcon
|
||||||
|
case 'plan': return ListTodoIcon
|
||||||
|
case 'step_running': case 'step_done': case 'step_failed':
|
||||||
|
case 'tool_running': case 'tool_done': case 'tool_error':
|
||||||
|
return null // use status icon instead
|
||||||
|
case 'knowledge': return SparklesIcon
|
||||||
|
case 'complete': return FlagIcon
|
||||||
|
case 'question': return HelpCircleIcon
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: ActivityEntry['status']) {
|
||||||
|
if (status === 'running') return 'text-primary'
|
||||||
|
if (status === 'failed') return 'text-destructive'
|
||||||
|
return 'text-success'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<span class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Activity</span>
|
||||||
|
{#if $streaming}
|
||||||
|
<span class="flex items-center gap-1 text-[11px] text-primary">
|
||||||
|
<LoaderCircleIcon class="size-3 animate-spin" />
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if $activityLog.length === 0}
|
||||||
|
<div class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
Waiting for agent activity…
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col py-1">
|
||||||
|
{#each $activityLog as entry, i (entry.id)}
|
||||||
|
{@const isLast = i === $activityLog.length - 1}
|
||||||
|
{@const hasDetail = !!entry.detail}
|
||||||
|
{@const icon = typeIcon(entry.type)}
|
||||||
|
<div class="relative">
|
||||||
|
<!-- connector line -->
|
||||||
|
{#if !isLast}
|
||||||
|
<div class="absolute left-[17px] top-6 bottom-0 w-px bg-border"></div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-start gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted/30 {hasDetail ? 'cursor-pointer' : 'cursor-default'}"
|
||||||
|
onclick={() => hasDetail && toggle(entry.id)}
|
||||||
|
>
|
||||||
|
<!-- status icon -->
|
||||||
|
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
|
||||||
|
{#if entry.status === 'running'}
|
||||||
|
<LoaderCircleIcon class="size-3.5 animate-spin" />
|
||||||
|
{:else if entry.status === 'failed'}
|
||||||
|
<CircleXIcon class="size-3.5" />
|
||||||
|
{:else if icon}
|
||||||
|
<svelte:component this={icon} class="size-3" />
|
||||||
|
{:else}
|
||||||
|
<CircleCheckIcon class="size-3" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<!-- description -->
|
||||||
|
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
|
||||||
|
{entry.description}
|
||||||
|
</span>
|
||||||
|
{#if hasDetail}
|
||||||
|
<span class="mt-0.5 shrink-0 text-muted-foreground">
|
||||||
|
{#if expanded.has(entry.id)}
|
||||||
|
<ChevronDownIcon class="size-3" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRightIcon class="size-3" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<!-- detail (collapsed) -->
|
||||||
|
{#if hasDetail && expanded.has(entry.id)}
|
||||||
|
<div class="pl-8 pr-3 pb-1">
|
||||||
|
<pre class="whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[10px] text-muted-foreground">{entry.detail}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ToolCallResult } from '$lib/stores/chat'
|
import type { ActivityEntry } from '$lib/stores/chat'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
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 { active = false, lastActivity = null as ActivityEntry | null, error = '' }: { active?: boolean; lastActivity?: ActivityEntry | null; error?: string } = $props()
|
||||||
|
|
||||||
let done = $state(false)
|
let done = $state(false)
|
||||||
let wasActive = $state(false)
|
let wasActive = $state(false)
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
if (active) { done = false; wasActive = true }
|
if (active) { done = false; wasActive = true }
|
||||||
if (!active && wasActive) {
|
if (!active && wasActive) {
|
||||||
done = true
|
done = true
|
||||||
// Fade out after 3s
|
|
||||||
const t = setTimeout(() => { done = false; wasActive = false }, 3000)
|
const t = setTimeout(() => { done = false; wasActive = false }, 3000)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}
|
}
|
||||||
@@ -22,33 +21,8 @@
|
|||||||
const label = $derived.by(() => {
|
const label = $derived.by(() => {
|
||||||
if (error) return error
|
if (error) return error
|
||||||
if (!active && done) return 'Done'
|
if (!active && done) return 'Done'
|
||||||
if (!lastTool) return 'Agent is thinking…'
|
if (lastActivity) return lastActivity.description
|
||||||
const args = lastTool.args ?? {}
|
return 'Agent is thinking…'
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,22 @@
|
|||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { startWorkspace } from '$lib/stores/workspace'
|
import { startWorkspace } from '$lib/stores/workspace'
|
||||||
import GoalHeader from './GoalHeader.svelte'
|
import GoalHeader from './GoalHeader.svelte'
|
||||||
|
import PlanProgress from './PlanProgress.svelte'
|
||||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||||
import SessionGraph from './SessionGraph.svelte'
|
import SessionGraph from './SessionGraph.svelte'
|
||||||
import SessionDigest from './SessionDigest.svelte'
|
import ActivityTimeline from './ActivityTimeline.svelte'
|
||||||
|
|
||||||
onMount(() => startWorkspace())
|
onMount(() => startWorkspace())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
|
||||||
The task's live control panel: goal + status, plan progress, a pinned
|
|
||||||
question when the agent needs a decision, the live entity graph (pulses what
|
|
||||||
the agent is touching, flags health changes), and the outcome/knowledge
|
|
||||||
record once the task completes. Driven by the always-on events stream
|
|
||||||
(see workspace.ts) so it keeps updating during server-side auto-continuation,
|
|
||||||
not just while a chat turn is streaming.
|
|
||||||
-->
|
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<GoalHeader />
|
<GoalHeader />
|
||||||
|
<PlanProgress />
|
||||||
<OperatorQuestion />
|
<OperatorQuestion />
|
||||||
<div class="min-h-0 flex-1">
|
<div class="min-h-0 flex-1">
|
||||||
<SessionGraph />
|
<SessionGraph />
|
||||||
</div>
|
</div>
|
||||||
<SessionDigest />
|
<div class="max-h-[40%] overflow-y-auto border-t">
|
||||||
|
<ActivityTimeline />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { writable, derived, get } from 'svelte/store'
|
import { writable, derived, get } from 'svelte/store'
|
||||||
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
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'
|
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||||
|
|
||||||
export interface PendingApproval {
|
export interface PendingApproval {
|
||||||
@@ -81,36 +82,150 @@ export function addChatError(message: string, action?: string) {
|
|||||||
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action }])
|
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action }])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolTimelineEntry — one tool call from the chat transcript, flattened for
|
// ── Activity timeline ────────────────────────────────────────────────
|
||||||
// the sidebar activity timeline. Derived from messages in real time.
|
|
||||||
export interface ToolTimelineEntry {
|
export interface ActivityEntry {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
type: 'goal' | 'plan' | 'step_running' | 'step_done' | 'step_failed' |
|
||||||
args?: any
|
'tool_running' | 'tool_done' | 'tool_error' |
|
||||||
result?: any
|
'knowledge' | 'complete' | 'question' | 'error'
|
||||||
error?: string
|
description: string
|
||||||
type: 'tool_use' | 'tool_result'
|
detail?: string
|
||||||
msgIndex: number // which message this tool belongs to
|
timestamp: number
|
||||||
|
toolName?: string
|
||||||
|
status: 'running' | 'done' | 'failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toolTimeline = derived(messages, ($msgs) => {
|
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => {
|
||||||
const entries: ToolTimelineEntry[] = []
|
const entries: ActivityEntry[] = []
|
||||||
for (let i = 0; i < $msgs.length; i++) {
|
const now = Date.now()
|
||||||
for (const t of $msgs[i].tools) {
|
|
||||||
entries.push({
|
// Goal
|
||||||
id: t.id ?? crypto.randomUUID(),
|
if ($task?.goal) {
|
||||||
name: t.name,
|
entries.push({ id: 'goal', type: 'goal', description: $task.goal, timestamp: 0, status: 'done' })
|
||||||
args: t.args,
|
}
|
||||||
result: t.result,
|
|
||||||
error: t.error,
|
// Plan steps
|
||||||
type: t.type,
|
for (const s of $steps) {
|
||||||
msgIndex: i
|
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
|
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
|
// Per-session controller tracking. Multiple tasks can stream concurrently
|
||||||
// (see sendMessage's session guard above this used to be a single global
|
// (see sendMessage's session guard above this used to be a single global
|
||||||
// `activeController`, which meant cancelStream()/newChat() always aborted
|
// `activeController`, which meant cancelStream()/newChat() always aborted
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError, toolTimeline } from '$lib/stores/chat'
|
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError, activityLog } from '$lib/stores/chat'
|
||||||
import { currentTask } from '$lib/stores/workspace'
|
import { currentTask } from '$lib/stores/workspace'
|
||||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
|
||||||
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
||||||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
||||||
import { getToolRenderer } from '$lib/tool-renderers'
|
import { getToolRenderer } from '$lib/tool-renderers'
|
||||||
@@ -53,11 +52,6 @@
|
|||||||
return matched.slice(0, MAX_INLINE_CARDS)
|
return matched.slice(0, MAX_INLINE_CARDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRemaining(msg: { tools: any[] }, inline: any[]): any[] {
|
|
||||||
const inlineIds = new Set(inline.map((t) => t.id))
|
|
||||||
return msg.tools.filter((t) => !inlineIds.has(t.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resizable right rail (session graph). Persisted so it survives reloads.
|
// Resizable right rail (session graph). Persisted so it survives reloads.
|
||||||
const RAIL_MIN = 260
|
const RAIL_MIN = 260
|
||||||
const RAIL_MAX = 620
|
const RAIL_MAX = 620
|
||||||
@@ -158,11 +152,6 @@
|
|||||||
<renderer.component {tool} />
|
<renderer.component {tool} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<ToolCallGroup
|
|
||||||
tools={msg.tools}
|
|
||||||
unmatched={getRemaining(msg, getInlineTools(msg))}
|
|
||||||
active={$streaming && i === $messages.length - 1}
|
|
||||||
/>
|
|
||||||
{#if msg.text}
|
{#if msg.text}
|
||||||
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||||
@@ -178,7 +167,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
<AgentIndicator
|
<AgentIndicator
|
||||||
active={$streaming || liveStatus === 'executing'}
|
active={$streaming || liveStatus === 'executing'}
|
||||||
lastTool={$toolTimeline.filter((t) => t.type === 'tool_use').at(-1) ?? null}
|
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
|
||||||
error={$error}
|
error={$error}
|
||||||
/>
|
/>
|
||||||
<div bind:this={messagesEnd}></div>
|
<div bind:this={messagesEnd}></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user