session reliability: reconnect, knowledge loop, retire request_execution
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

Phase 1 — crash recovery: SSE auto-reconnect + backoff, polling gate
during disconnect, connection banner with retry button, empty-response
retry 3x, non-terminal resume on empty response, persistent error cards.

Phase 2/4 — visibility + continuation: custom ExecutionStatus renderer,
approvals extracted on every tool_result (not just done), activity bar
with status/goal, SessionDigest live polling, Continue button.

Phase 3 — cleanup: complete_task auto-cancels orphaned approvals,
deletes assent/destructive window keys, propose_plan marks pending
steps as replaced, plan step seq-order enforcement.

Phase 5 — knowledge loop: list_lxcs state filter (active/destroyed),
SOUL.md unmissable writeback section, propose_plan validation nudge,
complete_task writeback check, upsert_knowledge about array support,
plan generation grouping in frontend, session approval count badge.

Retire request_execution — all mutations now route through run.
Updated SOUL.md, AGENTS.md, CLIENTS.md, skills, and agent system notes.

Migration 020: plan step generation column, audit_log session_id index,
nomos_plan_executions pending-approval index.
This commit is contained in:
2026-07-14 11:03:23 +02:00
parent b446909ea5
commit 60effcb2fe
25 changed files with 1894 additions and 323 deletions

View File

@@ -20,6 +20,7 @@ export interface Session {
outcome?: string // success | failure | partial
summary?: string
entity_id?: string
pending_approvals?: number
created_at: string
last_active_at: string
}
@@ -51,12 +52,18 @@ export async function deleteSession(sessionId: string): Promise<boolean> {
return res.ok
}
export async function resumeSession(sessionId: string): Promise<boolean> {
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/resume`, { method: 'POST' })
return res.ok
}
export interface PlanStep {
id: string
seq: number
title: string
detail: string
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked'
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked' | 'replaced'
generation?: number
execution_id?: string
target_slug?: string
started_at?: string

View File

@@ -7,19 +7,32 @@
import CircleXIcon from '@lucide/svelte/icons/circle-x'
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
const done = $derived($planSteps.filter((s) => s.status === 'done').length)
const total = $derived($planSteps.length)
const pct = $derived(total > 0 ? Math.round((done / total) * 100) : 0)
// Group steps by generation. The latest generation is shown expanded;
// older ones are collapsible.
const byGeneration = $derived.by(() => {
const groups: Map<number, typeof $planSteps> = new Map()
for (const s of $planSteps) {
const g = s.generation ?? 1
if (!groups.has(g)) groups.set(g, [])
groups.get(g)!.push(s)
}
return Array.from(groups.entries()).sort(([a], [b]) => a - b)
})
const latestGen = $derived(byGeneration.length > 0 ? byGeneration[byGeneration.length - 1][0] : 0)
let openGens = $state(new Set<number>())
let sheetSlug = $state<string | null>(null)
let sheetOpen = $state(false)
// Tool calls don't carry a step id, so a step can't be linked to its exact
// transcript entry — but its target entity IS known, and EntitySheet already
// gives a real, working detail view for any slug. Clicking a step with a
// target opens that, rather than a fake "scroll to it" that would silently
// no-op for a collapsed tool-call group.
function openStep(targetSlug: string | undefined) {
if (!targetSlug) return
sheetSlug = targetSlug
@@ -36,41 +49,61 @@
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {pct}%"></div>
</div>
<ol class="flex flex-col gap-1">
{#each $planSteps as step (step.id)}
<li>
<button
type="button"
class="flex w-full items-start gap-2 rounded px-1 py-1 text-left text-xs {step.target_slug ? 'hover:bg-muted/50' : 'cursor-default'}"
onclick={() => openStep(step.target_slug)}
>
<span class="mt-0.5 shrink-0">
{#if step.status === 'done'}
<CircleCheckIcon class="size-3.5 text-success" />
{:else if step.status === 'failed'}
<CircleXIcon class="size-3.5 text-destructive" />
{:else if step.status === 'running'}
<LoaderCircleIcon class="size-3.5 animate-spin text-primary" />
{:else if step.status === 'skipped'}
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
{:else if step.status === 'blocked'}
<CirclePauseIcon class="size-3.5 text-warning" />
{:else}
<CircleIcon class="size-3.5 text-muted-foreground" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block leading-snug {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
{step.title}
</span>
{#if step.target_slug}
<span class="font-mono text-[10px] text-muted-foreground">{step.target_slug}</span>
{/if}
</span>
</button>
</li>
{/each}
</ol>
{#each byGeneration as [gen, steps] (gen)}
{@const isLatest = gen === latestGen}
{#if byGeneration.length > 1}
<button
type="button"
class="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
onclick={() => openGens.has(gen) ? openGens.delete(gen) : openGens.add(gen); openGens = new Set(openGens)}
>
{#if openGens.has(gen) || isLatest}
<ChevronDownIcon class="size-3" />
{:else}
<ChevronRightIcon class="size-3" />
{/if}
<span>{isLatest ? 'Current plan' : `Plan v${gen} (replaced)`}</span>
</button>
{/if}
{#if isLatest || openGens.has(gen)}
<ol class="flex flex-col gap-1">
{#each steps as step (step.id)}
<li>
<button
type="button"
class="flex w-full items-start gap-2 rounded px-1 py-1 text-left text-xs {step.target_slug ? 'hover:bg-muted/50' : 'cursor-default'} {gen !== latestGen ? 'opacity-50' : ''}"
onclick={() => openStep(step.target_slug)}
>
<span class="mt-0.5 shrink-0">
{#if step.status === 'done'}
<CircleCheckIcon class="size-3.5 text-success" />
{:else if step.status === 'failed'}
<CircleXIcon class="size-3.5 text-destructive" />
{:else if step.status === 'running'}
<LoaderCircleIcon class="size-3.5 animate-spin text-primary" />
{:else if step.status === 'skipped' || step.status === 'replaced'}
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
{:else if step.status === 'blocked'}
<CirclePauseIcon class="size-3.5 text-warning" />
{:else}
<CircleIcon class="size-3.5 text-muted-foreground" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block leading-snug {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
{step.title}
</span>
{#if step.target_slug}
<span class="font-mono text-[10px] text-muted-foreground">{step.target_slug}</span>
{/if}
</span>
</button>
</li>
{/each}
</ol>
{/if}
{/each}
</div>
{/if}

View File

@@ -16,18 +16,27 @@
// still refetch once outcome/summary land, not just on session switch.
let loadedKey = $state<string | null>(null)
// Reload the digest whenever the session changes, the task's status changes
// (e.g. it just completed), or a stream finishes — "what did this session
// actually do" is only meaningful once executions have had a chance to land.
let pollTimer: ReturnType<typeof setInterval> | null = $state(null)
$effect(() => {
const sid = $currentSession
const busy = $streaming
const status = $currentTask?.status ?? ''
if (!sid || busy) return
const key = `${sid}:${status}`
if (loadedKey === key) return
loadedKey = key
fetchSessionDigest(sid).then((d) => (digest = d))
if (loadedKey !== key) {
loadedKey = key
fetchSessionDigest(sid).then((d) => (digest = d))
}
// Poll every 10s while the session is active.
if (status !== 'done' && status !== 'failed') {
if (!pollTimer) pollTimer = setInterval(() => fetchSessionDigest(sid).then((d) => (digest = d)), 10000)
} else {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
return () => {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
})
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {

View File

@@ -51,7 +51,12 @@
onclick={() => handleClick(session.id)}
>
<span class="min-w-0 max-w-full truncate font-medium">{session.title || 'Untitled'}</span>
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{relativeTime(session.last_active_at)}
{#if session.pending_approvals}
<span class="rounded bg-warning/20 px-1 text-[10px] font-medium text-warning">{session.pending_approvals}</span>
{/if}
</span>
</button>
<button
type="button"

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import { getExecution } from '$lib/api'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ClockIcon from '@lucide/svelte/icons/clock'
let { tool }: { tool: ToolCallResult } = $props()
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const executions = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = Array.isArray(tool.result) ? tool.result : (tool.result as any)?.data
return Array.isArray(data) ? data as any[] : null
})
const statusColors: Record<string, string> = {
completed: 'var(--success)',
failed: 'var(--destructive)',
cancelled: 'var(--destructive)',
denied: 'var(--destructive)',
revoked: 'var(--destructive)',
running: 'var(--warning)',
approved: 'var(--warning)',
pending_approval: 'var(--muted-foreground)',
queued: 'var(--muted-foreground)',
}
function statusLabel(s: string): string {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function fmtDuration(ms?: number): string {
if (!ms) return ''
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m ${s % 60}s`
}
function truncate(s: string, n: number): string {
if (!s) return ''
return s.length > n ? s.slice(0, n) + '…' : s
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Execution status</span>
<span class="animate-pulse text-muted-foreground">checking…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Execution status</span>
<span class="text-destructive">{error}</span>
</div>
{:else if executions && executions.length > 0}
<div class="rounded-lg border bg-card text-xs">
<div class="flex flex-col divide-y">
{#each executions as exec (exec.execution_id ?? exec.id)}
{@const status = exec.status ?? 'unknown'}
{@const color = statusColors[status] ?? 'var(--muted-foreground)'}
{@const isRunning = status === 'running' || status === 'approved'}
<div class="flex items-center gap-2 px-3 py-2">
{#if status === 'completed'}
<CheckIcon class="size-3 shrink-0" style="color: {color}" aria-hidden="true" />
{:else if status === 'failed' || status === 'cancelled' || status === 'denied' || status === 'revoked'}
<XIcon class="size-3 shrink-0" style="color: {color}" aria-hidden="true" />
{:else if isRunning}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin" style="color: {color}" aria-hidden="true" />
{:else}
<ClockIcon class="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
{/if}
<span class="font-mono font-medium">{truncate(exec.execution_id ?? exec.id ?? '', 12)}</span>
<span class="text-muted-foreground">{statusLabel(status)}</span>
{#if exec.action}
<span class="text-muted-foreground">· {truncate(exec.action, 40)}</span>
{/if}
{#if exec.duration_ms}
<span class="text-muted-foreground">· {fmtDuration(exec.duration_ms)}</span>
{/if}
<span class="ml-auto inline-block rounded px-1.5 py-0.5 font-medium text-[10px]" style="background: {color}22; color: {color}">
{statusLabel(status)}
</span>
</div>
{#if exec.result || exec.error}
<div class="max-h-32 overflow-y-auto bg-background/60 px-3 py-1.5">
{#if exec.error}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-destructive">{exec.error}</pre>
{:else if exec.result}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{typeof exec.result === 'string' ? exec.result : JSON.stringify(exec.result, null, 2)}</pre>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Execution status</span>
<span class="text-muted-foreground">no active executions</span>
</div>
{/if}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import ExecutionStatus from './ExecutionStatus.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_execution_status',
component: ExecutionStatus,
})
}

View File

@@ -7,6 +7,7 @@ import { init as initBlastRadius } from './blast-radius'
import { init as initChangeLog } from './change-log'
import { init as initFleetSnapshot } from './fleet-snapshot'
import { init as initMetricChart } from './metric-chart'
import { init as initExecutionStatus } from './execution-status'
initEntityCard()
initHealthSummary()
@@ -17,3 +18,4 @@ initBlastRadius()
initChangeLog()
initFleetSnapshot()
initMetricChart()
initExecutionStatus()

View File

@@ -40,7 +40,7 @@ function extractApprovals(tools: ToolCallResult[]): PendingApproval[] {
if (m) {
out.push({
executionId: m[1],
action: t.args?.action ?? t.args?.purpose ?? t.name ?? 'unknown',
action: t.args?.purpose?.slice(0, 60) ?? t.args?.action ?? t.name ?? 'unknown',
target: t.args?.target ?? 'unknown',
destructive: /\bDESTRUCTIVE\b/.test(text),
command: t.args?.command,
@@ -66,10 +66,20 @@ function mid(): string {
export const messages = writable<ChatMessage[]>([])
export const streaming = writable(false)
export const connectionState = writable<'connected' | 'disconnected' | 'reconnecting'>('connected')
export const currentSession = writable<string | null>(null)
export const sessions = writable<Session[]>([])
export const sessionMessages = writable<Message[]>([])
export const error = writable<string | null>(null)
export const chatErrors = writable<{ id: string; message: string; action?: string }[]>([])
export function dismissError(id: string) {
chatErrors.update((e) => e.filter((x) => x.id !== id))
}
export function addChatError(message: string, action?: string) {
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action }])
}
// Per-session controller tracking. Multiple tasks can stream concurrently
// (see sendMessage's session guard above this used to be a single global
@@ -153,7 +163,9 @@ function startPolling(sessionId: string) {
stopPolling()
pollingSessionId = sessionId
pollTimer = setInterval(async () => {
if (get(streaming)) return
// Allow polling while disconnected — the agent is still working
// server-side and the poller is the only way to see it.
if (get(streaming) && get(connectionState) === 'connected') return
if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return
const msgs = await fetchMessages(sessionId)
if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time
@@ -217,6 +229,7 @@ export function sendMessage(text: string) {
// auto-continuation.
const openedFor = get(currentSession)
let streamSessionID = openedFor
let receivedDone = false
const controller = streamChat(
text,
@@ -271,6 +284,7 @@ export function sendMessage(text: string) {
last.tools = last.tools.map((t) =>
t.id === ev.data.id ? updated : t
)
last.pendingApprovals = extractApprovals(last.tools)
}
return [...ms]
})
@@ -293,6 +307,8 @@ export function sendMessage(text: string) {
return [...ms]
})
} else if (ev.type === 'done') {
receivedDone = true
connectionState.set('connected')
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
@@ -314,14 +330,29 @@ export function sendMessage(text: string) {
}
},
(err: string) => {
if (get(currentSession) === streamSessionID) error.set(err)
// Distinguish user abort from network drop.
if (err === 'AbortError' || err.includes('aborted')) {
if (get(currentSession) === streamSessionID) streaming.set(false)
return
}
// Network blip / server restart — initiate reconnect.
if (get(currentSession) === streamSessionID) {
error.set(err)
if (!receivedDone && streamSessionID) {
handleDisconnect(streamSessionID)
} else {
streaming.set(false)
}
}
},
() => {
if (get(currentSession) === streamSessionID) streaming.set(false)
// Clean up whichever slot this controller ended up in — normally
// activeControllers[streamSessionID] once the 'session' event has
// fired, but fall back to pendingController for the (rare) case where
// the stream errored/completed before ever getting one.
// SSE stream completed without error. If we never received 'done',
// the connection was severed mid-turn — treat as disconnect.
if (!receivedDone && streamSessionID && get(currentSession) === streamSessionID) {
handleDisconnect(streamSessionID)
} else if (get(currentSession) === streamSessionID) {
streaming.set(false)
}
if (streamSessionID && activeControllers.get(streamSessionID) === controller) {
activeControllers.delete(streamSessionID)
}
@@ -340,12 +371,87 @@ export function sendMessage(text: string) {
}
}
// handleDisconnect is called when the SSE stream drops mid-turn without
// receiving a 'done' event. Falls back to polling and attempts reconnection.
function handleDisconnect(sessionId: string) {
const MAX_RECONNECT = 3
connectionState.set('disconnected')
startPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
let attempts = 0
let delay = 1000
const attemptReconnect = () => {
if (get(currentSession) !== sessionId || attempts >= MAX_RECONNECT) {
connectionState.set('disconnected')
streaming.set(false)
return
}
if (attempts > 0) {
connectionState.set('reconnecting')
addChatError(`Reconnecting to agent (attempt ${attempts + 1}/${MAX_RECONNECT})…`, 'Dismiss')
}
attempts++
const controller = streamChat(
'',
sessionId,
(_ev: ChatEvent) => {},
(_err: string) => {
delay = Math.min(delay * 2, 8000)
setTimeout(attemptReconnect, delay)
},
() => {
if (get(currentSession) === sessionId) {
connectionState.set('connected')
streaming.set(false)
loadSessionMessages(sessionId)
}
}
)
if (activeControllers.get(sessionId)) {
activeControllers.get(sessionId)?.abort()
}
activeControllers.set(sessionId, controller)
}
setTimeout(attemptReconnect, delay)
}
export function reconnect() {
const sid = get(currentSession)
if (!sid) return
connectionState.set('reconnecting')
const controller = streamChat(
'',
sid,
(_ev: ChatEvent) => {},
(_err: string) => {
connectionState.set('disconnected')
addChatError('Reconnect failed. The task may still be running — try sending a message to wake the agent.', 'Dismiss')
},
() => {
if (get(currentSession) === sid) {
connectionState.set('connected')
streaming.set(false)
loadSessionMessages(sid)
}
}
)
if (activeControllers.get(sid)) {
activeControllers.get(sid)?.abort()
}
activeControllers.set(sid, controller)
}
export function newChat() {
cancelStream()
stopPolling()
connectionState.set('connected')
currentSession.set(null)
messages.set([])
error.set(null)
chatErrors.set([])
streaming.set(false) // fresh view — see loadSessionMessages for why this must not depend on cancelStream's own reset
}

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } 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'
@@ -8,7 +10,9 @@
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'
@@ -94,6 +98,12 @@
if ($streaming) return
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>
<div class="flex h-full min-h-0">
@@ -121,6 +131,30 @@
</div>
{/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)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
@@ -161,6 +195,23 @@
</div>
</div>
{#if $connectionState === 'disconnected'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={reconnect}>Reconnect</Button>
</div>
</div>
{:else if $connectionState === 'reconnecting'}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
</div>
</div>
{/if}
{#if $error}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -169,6 +220,18 @@
</div>
{/if}
{#each $chatErrors as err (err.id)}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span class="flex-1">{err.message}</span>
{#if err.action}
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => dismissError(err.id)}>{err.action}</Button>
{/if}
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => dismissError(err.id)} aria-label="Dismiss">×</button>
</div>
</div>
{/each}
<div class="border-t bg-card/50 p-3">
<form
class="mx-auto flex max-w-3xl items-end gap-2"