session reliability: reconnect, knowledge loop, retire request_execution
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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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"
|
||||
|
||||
108
web/src/lib/renderers/ExecutionStatus.svelte
Normal file
108
web/src/lib/renderers/ExecutionStatus.svelte
Normal 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}
|
||||
9
web/src/lib/renderers/execution-status.ts
Normal file
9
web/src/lib/renderers/execution-status.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user