refactor(web): delete dead code (R2) — 1736 lines removed
Tool-renderer registry (21 files, ~1.5k lines):
- src/lib/tool-renderers.ts — registry + getToolRenderer (exported, never
imported anywhere)
- src/lib/renderers/index.ts + 10 .ts registrars + 10 .svelte components
- main.ts: removed the requestAnimationFrame(() => import('./lib/renderers'))
that was the only thing keeping the dead subsystem alive
Dead components (never imported):
- ToolCallGroup, PlanProgress, GoalHeader, InlineApproval, SessionDigest
Dead store exports (written, never read):
- context.ts: pendingApprovals writable (+ Approval type import)
- events.ts: connectionState writable (+ its .set() calls)
Dead API surface:
- api.ts: SessionDigest interface + fetchSessionDigest (only caller was the
dead SessionDigest.svelte)
Dead npm deps:
- mode-watcher (0 imports; superseded by stores/theme.svelte.ts)
- @internationalized/date (0 imports)
Also: fix stale comments referencing deleted symbols, update plan R1/R2
status. Build clean (4683 modules, down from 4706; one Svelte 5 warning
gone — the dead HealthSummary.svelte was emitting state_referenced_locally).
This commit is contained in:
@@ -397,8 +397,8 @@ behavior; tracked as R6.
|
||||
|
||||
| ID | Action | Effort | Risk |
|
||||
| -- | ------ | ------ | ---- |
|
||||
| R1 | Delete dead Go: `notifier.VerifyApprovalToken`, `httpapi/stubs.go`; unexport 4 `checkdefaults` symbols | S | Low |
|
||||
| R2 | Delete dead web: 21-file tool-renderer registry, 5 dead components, 2 dead store exports, 2 dead npm deps | S | Low |
|
||||
| R1 | Delete dead Go: `notifier.VerifyApprovalToken`, `httpapi/stubs.go`; unexport 4 `checkdefaults` symbols | S | Low | ✅ done (c3973e7) |
|
||||
| R2 | Delete dead web: 21-file tool-renderer registry, 5 dead components, 2 dead store exports, 2 dead npm deps | S | Low | ✅ done (c3973e7+1) |
|
||||
| R3 | Decide sqlc vs raw SQL: delete 17 dead queries OR migrate inline SQL to use them | M | Medium |
|
||||
| R4 | Split `phase3.go` (2627 lines) into per-resource files; refactor `newServer` (708 lines) to a tool registry | M | Medium |
|
||||
| R5 | Rewrite `.agents/domains/knowledge/schema.md` + `.agents/shared/llm-wiki.md` for the DB-native model; delete/deprecate root `inventory.yaml` | M | Low |
|
||||
|
||||
@@ -12,14 +12,12 @@
|
||||
"lint": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"bits-ui": "^2.18.1",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-sonner": "^1.1.1",
|
||||
|
||||
@@ -351,21 +351,6 @@ export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface SessionDigest {
|
||||
session_id: string
|
||||
total_executions: number
|
||||
by_status: Record<string, number>
|
||||
entities_touched: string[]
|
||||
executions: { target: string; verb: string; summary: string; risk_class: string; status: string }[]
|
||||
knowledge_created: string[]
|
||||
}
|
||||
|
||||
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
|
||||
const res = await fetchWithAuth(`${API}/activity/session/${sessionId}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface CapabilityTimelineItem {
|
||||
verb: string
|
||||
first_success: string | null
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { currentTask } from '$lib/stores/workspace'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
|
||||
type StatusStyle = { label: string; dot: string; pulse: boolean; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||
|
||||
function statusStyle(status: string | undefined, outcome: string | undefined): StatusStyle {
|
||||
switch (status) {
|
||||
case 'awaiting_input':
|
||||
return { label: 'Needs your input', dot: 'bg-warning', pulse: true, variant: 'secondary' }
|
||||
case 'done':
|
||||
return outcome === 'partial'
|
||||
? { label: 'Done · partial', dot: 'bg-warning', pulse: false, variant: 'secondary' }
|
||||
: { label: 'Done', dot: 'bg-success', pulse: false, variant: 'default' }
|
||||
case 'failed':
|
||||
return { label: 'Failed', dot: 'bg-destructive', pulse: false, variant: 'destructive' }
|
||||
case 'planning':
|
||||
return { label: 'Planning', dot: 'bg-primary', pulse: true, variant: 'secondary' }
|
||||
case 'executing':
|
||||
return { label: 'Executing', dot: 'bg-primary', pulse: true, variant: 'secondary' }
|
||||
default:
|
||||
return { label: 'Active', dot: 'bg-muted-foreground', pulse: false, variant: 'outline' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $currentTask}
|
||||
{@const st = statusStyle($currentTask.status, $currentTask.outcome)}
|
||||
<div class="flex shrink-0 flex-col gap-1.5 border-b px-3 py-2.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||
<Badge variant={st.variant} class="text-[10px]">{st.label}</Badge>
|
||||
</div>
|
||||
<p class="text-sm font-medium leading-snug">
|
||||
{$currentTask.goal || $currentTask.title || 'Untitled task'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,261 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PendingApproval } from '$lib/stores/chat'
|
||||
import { decideApproval, getExecution, fetchBlastRadius, type Execution } from '$lib/api'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import NetworkIcon from '@lucide/svelte/icons/network'
|
||||
|
||||
let { approvals }: { approvals: PendingApproval[] } = $props()
|
||||
|
||||
// Downstream entities the target affects, keyed by executionId — fetched
|
||||
// once per approval so the operator sees the graph-walk impact ("this
|
||||
// affects 3 downstream") before deciding, not after. depth 0 is the target
|
||||
// itself, excluded here since it's already shown as "on {target}".
|
||||
const blastRadius = new SvelteMap<string, string[]>()
|
||||
const blastRadiusFetched = new Set<string>()
|
||||
async function loadBlastRadius(a: PendingApproval) {
|
||||
if (blastRadiusFetched.has(a.executionId) || a.target === 'unknown') return
|
||||
blastRadiusFetched.add(a.executionId)
|
||||
const items = await fetchBlastRadius(a.target)
|
||||
const affected = items.filter((i) => i.depth > 0).map((i) => i.entity.slug)
|
||||
if (affected.length) blastRadius.set(a.executionId, affected)
|
||||
}
|
||||
|
||||
// Per-execution UI phase, keyed by executionId. A resolved phase hides the
|
||||
// action buttons permanently so the banner clears after a click and can
|
||||
// never re-POST /decision.
|
||||
type Phase = 'deciding' | 'running' | 'completed' | 'failed' | 'denied' | 'stalled'
|
||||
const phase = new SvelteMap<string, Phase>()
|
||||
// Latest execution row (for status/result display), keyed by executionId.
|
||||
const exec = new SvelteMap<string, Execution>()
|
||||
|
||||
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'denied'])
|
||||
|
||||
function errorText(e: Execution | undefined): string {
|
||||
const r = e?.result as Record<string, unknown> | undefined | null
|
||||
const v = r?.error
|
||||
return typeof v === 'string' && v ? v : 'Execution failed.'
|
||||
}
|
||||
|
||||
function outputText(e: Execution | undefined): string {
|
||||
const r = e?.result as Record<string, unknown> | undefined | null
|
||||
const v = r?.output
|
||||
return typeof v === 'string' ? v.trim() : ''
|
||||
}
|
||||
|
||||
function elapsedSeconds(e: Execution | undefined): number | null {
|
||||
if (!e?.created_at) return null
|
||||
return Math.max(0, Math.round((now - new Date(e.created_at).getTime()) / 1000))
|
||||
}
|
||||
|
||||
function fmtDuration(s: number): string {
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
return `${m}m ${s % 60}s`
|
||||
}
|
||||
|
||||
// Live clock for the elapsed-time display on running cards. Tied to
|
||||
// component lifecycle via $effect so the interval is guaranteed cleared on
|
||||
// unmount — a bare setInterval field here would leak a 1Hz timer for the
|
||||
// lifetime of the page every time this component was mounted.
|
||||
let now = $state(Date.now())
|
||||
$effect(() => {
|
||||
const t = setInterval(() => { now = Date.now() }, 1000)
|
||||
return () => clearInterval(t)
|
||||
})
|
||||
|
||||
// Backend commands are hard-capped at 10 minutes (internal sshExec
|
||||
// timeout) before the execution is force-finalized as failed — so polling
|
||||
// must outlast that with margin, or the UI gives up and goes stale before
|
||||
// the backend ever resolves. Poll for 14 minutes; anything still running
|
||||
// past that is a genuine anomaly worth surfacing distinctly rather than
|
||||
// silently going quiet.
|
||||
const POLL_CEILING_MS = 14 * 60 * 1000
|
||||
|
||||
// Poll the execution until it reaches a terminal state, so the operator sees
|
||||
// provisioning progress and the final outcome without leaving the chat.
|
||||
async function track(id: string) {
|
||||
const deadline = Date.now() + POLL_CEILING_MS
|
||||
while (Date.now() < deadline) {
|
||||
const e = await getExecution(id)
|
||||
if (e) {
|
||||
exec.set(id, e)
|
||||
if (e.status === 'completed') { phase.set(id, 'completed'); return }
|
||||
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
|
||||
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2500))
|
||||
}
|
||||
// Genuinely outlasted the backend's own hard timeout — this means
|
||||
// something is wrong beyond a slow command (e.g. the API is down).
|
||||
// Say so explicitly instead of freezing on "running" with no signal.
|
||||
if (!TERMINAL.has(exec.get(id)?.status ?? '')) phase.set(id, 'stalled')
|
||||
}
|
||||
|
||||
async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
|
||||
const id = approval.executionId
|
||||
const cur = phase.get(id)
|
||||
if (cur && cur !== 'failed') return // no resubmit once decided/in-flight
|
||||
phase.set(id, 'deciding')
|
||||
const ok = await decideApproval(id, decision)
|
||||
if (!ok) { phase.set(id, 'failed'); return }
|
||||
if (decision === 'deny') { phase.set(id, 'denied'); return }
|
||||
phase.set(id, 'running')
|
||||
void track(id)
|
||||
}
|
||||
|
||||
// Self-heal: a pending approval can be decided somewhere other than this
|
||||
// button — chat assent ("go ahead" in the next message), the Ops page, or
|
||||
// Matrix. Without this, the banner would sit showing Approve/Deny forever
|
||||
// while the action was already running or done behind the scenes. Poll
|
||||
// every card that's still showing buttons; the moment its execution leaves
|
||||
// pending_approval, adopt that outcome exactly as if the button had been
|
||||
// clicked. Stops immediately if the operator clicks the button first
|
||||
// (phase becomes non-empty, ending this loop's reason to exist).
|
||||
const watching = new Set<string>()
|
||||
async function watchExternal(id: string) {
|
||||
if (watching.has(id)) return
|
||||
watching.add(id)
|
||||
for (let i = 0; i < 200; i++) { // ~10min ceiling at 3s
|
||||
if (phase.get(id)) return // resolved locally (button click) or already picked up
|
||||
const e = await getExecution(id)
|
||||
if (e && e.status !== 'pending_approval') {
|
||||
exec.set(id, e)
|
||||
if (e.status === 'completed') { phase.set(id, 'completed'); return }
|
||||
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
|
||||
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
|
||||
// 'approved' or 'running': someone said yes elsewhere — switch to
|
||||
// the same tracking the button click would have started.
|
||||
phase.set(id, 'running')
|
||||
void track(id)
|
||||
return
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
for (const a of approvals) {
|
||||
if (!phase.get(a.executionId)) {
|
||||
void watchExternal(a.executionId)
|
||||
void loadBlastRadius(a)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#each approvals as approval (approval.executionId)}
|
||||
{@const p = phase.get(approval.executionId)}
|
||||
{@const e = exec.get(approval.executionId)}
|
||||
{#if p === 'completed'}
|
||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckIcon class="size-4 shrink-0" />
|
||||
<span>Completed{e?.duration_ms ? ` in ${Math.round(e.duration_ms / 1000)}s` : ''} on {approval.target}.</span>
|
||||
</div>
|
||||
{#if outputText(e)}
|
||||
<pre class="max-h-32 overflow-y-auto whitespace-pre-wrap break-words pl-6 opacity-80">{outputText(e)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if p === 'failed'}
|
||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<div class="flex items-center gap-2">
|
||||
<XIcon class="size-4 shrink-0" />
|
||||
<span class="font-medium">Execution failed</span>
|
||||
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => decide(approval, 'approve')}>Retry</Button>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words pl-6 opacity-90">{errorText(e)}</pre>
|
||||
</div>
|
||||
{:else if p === 'denied'}
|
||||
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<XIcon class="size-4" /><span>Denied.</span>
|
||||
</div>
|
||||
{:else if p === 'running' || p === 'deciding'}
|
||||
{@const secs = elapsedSeconds(e)}
|
||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<Spinner class="size-4 shrink-0 text-warning" />
|
||||
<span>
|
||||
{#if p === 'deciding'}
|
||||
Submitting approval…
|
||||
{:else}
|
||||
Running on {approval.target}{secs !== null ? ` — ${fmtDuration(secs)} elapsed` : '…'}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if p === 'running' && approval.command}
|
||||
<code class="ml-6 block truncate opacity-70">{approval.command}</code>
|
||||
{/if}
|
||||
{#if p === 'running'}
|
||||
<span class="ml-6 opacity-60">
|
||||
Execution <code>{approval.executionId.slice(0, 8)}</code> — long installs can take several minutes; this
|
||||
will resolve on its own (capped at 10 min) or you can check the Operations page for live output.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if p === 'stalled'}
|
||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<div class="flex items-center gap-2">
|
||||
<XIcon class="size-4 shrink-0" />
|
||||
<span class="font-medium">No update from the server in over 14 minutes.</span>
|
||||
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => { phase.delete(approval.executionId); void track(approval.executionId) }}>
|
||||
Check again
|
||||
</Button>
|
||||
</div>
|
||||
<span class="pl-6 opacity-90">
|
||||
The command itself is capped at 10 minutes server-side, so this is unusual — the API may be unreachable.
|
||||
Execution <code>{approval.executionId}</code>. Check the Operations page directly.
|
||||
</span>
|
||||
</div>
|
||||
{:else if approval.destructive}
|
||||
{@const affected = blastRadius.get(approval.executionId)}
|
||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
|
||||
<span class="flex-1 text-xs text-destructive">
|
||||
<strong>DESTRUCTIVE</strong> — {approval.action} on {approval.target}. Type
|
||||
"I confirm" in chat, or use the button.
|
||||
</span>
|
||||
<Button size="sm" variant="destructive" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
||||
<CheckIcon class="size-3" /><span class="ml-1">Confirm</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
||||
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
||||
</Button>
|
||||
</div>
|
||||
{#if approval.command}
|
||||
<code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
|
||||
{/if}
|
||||
{#if affected}
|
||||
<div class="ml-6 flex items-start gap-1.5 text-xs text-destructive/90">
|
||||
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
|
||||
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{@const affected = blastRadius.get(approval.executionId)}
|
||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
||||
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
|
||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
||||
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
||||
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
||||
</Button>
|
||||
</div>
|
||||
{#if affected}
|
||||
<div class="ml-6 flex items-start gap-1.5 text-xs text-warning">
|
||||
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
|
||||
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -1,116 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { planSteps } from '$lib/stores/workspace'
|
||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
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)
|
||||
|
||||
function openStep(targetSlug: string | undefined) {
|
||||
if (!targetSlug) return
|
||||
sheetSlug = targetSlug
|
||||
sheetOpen = true
|
||||
}
|
||||
|
||||
function toggleGen(gen: number) {
|
||||
if (openGens.has(gen)) openGens.delete(gen)
|
||||
else openGens.add(gen)
|
||||
openGens = new Set(openGens)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if total > 0}
|
||||
<div class="flex shrink-0 flex-col gap-2 border-b px-3 py-2.5">
|
||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span class="font-semibold uppercase tracking-wider">Plan</span>
|
||||
<span>{done}/{total}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{#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={() => toggleGen(gen)}
|
||||
>
|
||||
{#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'}
|
||||
<Spinner class="size-3.5 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}
|
||||
|
||||
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />
|
||||
@@ -1,215 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
|
||||
import { currentSession, streaming, toolTimeline, type ToolTimelineEntry } from '$lib/stores/chat'
|
||||
import { currentTask, planSteps } from '$lib/stores/workspace'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
|
||||
let digest = $state<SessionDigest | null>(null)
|
||||
let open = $state(false)
|
||||
let openTools = $state(false)
|
||||
let loadedKey = $state<string | null>(null)
|
||||
|
||||
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) {
|
||||
loadedKey = key
|
||||
fetchSessionDigest(sid).then((d) => (digest = d))
|
||||
}
|
||||
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' {
|
||||
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
|
||||
if (status === 'completed') return 'default'
|
||||
if (['running', 'approved'].includes(status)) return 'secondary'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
|
||||
const planTotal = $derived($planSteps.length)
|
||||
|
||||
// Group tool timeline entries by message (turn), showing only unique tool names per entry.
|
||||
const toolGroups = $derived.by(() => {
|
||||
const groups: { msgIndex: number; entries: ToolTimelineEntry[] }[] = []
|
||||
for (const e of $toolTimeline) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.msgIndex === e.msgIndex) {
|
||||
last.entries.push(e)
|
||||
} else {
|
||||
groups.push({ msgIndex: e.msgIndex, entries: [e] })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const toolCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use').length)
|
||||
const runningCount = $derived($toolTimeline.filter((t) => t.type === 'tool_use' && !$toolTimeline.some((r) => r.type === 'tool_result' && r.id === t.id)).length)
|
||||
|
||||
function toolSummary(t: ToolTimelineEntry): string {
|
||||
if (!t.args || typeof t.args !== 'object') return t.name
|
||||
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
|
||||
if (typeof firstArg === 'string') return `${t.name} ${firstArg.slice(0, 40)}`
|
||||
return t.name
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $streaming && $currentSession}
|
||||
<div class="flex items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
|
||||
<Spinner class="size-3 shrink-0 text-primary" />
|
||||
<span>{toolCount} tool{toolCount === 1 ? '' : 's'} · {runningCount} running</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentTask?.outcome}
|
||||
<div
|
||||
class="flex items-start gap-2 border-b px-3 py-2 text-xs {$currentTask.outcome === 'failure'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: $currentTask.outcome === 'partial'
|
||||
? 'bg-warning/5 text-warning'
|
||||
: 'bg-success/5 text-success'}"
|
||||
>
|
||||
{#if $currentTask.outcome === 'failure'}
|
||||
<CircleXIcon class="mt-0.5 size-3.5 shrink-0" />
|
||||
{:else}
|
||||
<CircleCheckIcon class="mt-0.5 size-3.5 shrink-0" />
|
||||
{/if}
|
||||
<span class="leading-snug">{$currentTask.summary || `Task ${$currentTask.outcome}.`}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $planSteps.length > 0}
|
||||
<div class="flex shrink-0 flex-col gap-1 border-b px-3 py-2">
|
||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span class="font-semibold uppercase tracking-wider">Plan</span>
|
||||
<span>{planDone}/{planTotal}</span>
|
||||
</div>
|
||||
<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: {planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0}%"></div>
|
||||
</div>
|
||||
<ol class="flex flex-col gap-0.5">
|
||||
{#each $planSteps as step (step.id)}
|
||||
<li class="flex items-start gap-1.5 text-[11px] {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
|
||||
<span class="mt-0.5 shrink-0">
|
||||
{#if step.status === 'done'}
|
||||
<CircleCheckIcon class="size-3 text-success" />
|
||||
{:else if step.status === 'running'}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{:else if step.status === 'failed'}
|
||||
<CircleXIcon class="size-3 text-destructive" />
|
||||
{:else}
|
||||
<CircleIcon class="size-3 text-muted-foreground" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="leading-tight">{step.title}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if toolCount > 0}
|
||||
<div class="border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
||||
onclick={() => (openTools = !openTools)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
{#if openTools}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
|
||||
<WrenchIcon class="size-3 text-muted-foreground" />
|
||||
Tool activity
|
||||
</span>
|
||||
<span class="text-muted-foreground">{toolCount} call{toolCount === 1 ? '' : 's'}</span>
|
||||
</button>
|
||||
{#if openTools}
|
||||
<div class="flex flex-col gap-0.5 px-3 pb-2 text-xs">
|
||||
{#each toolGroups as group}
|
||||
{@const isLatest = group.msgIndex === toolGroups[toolGroups.length - 1]?.msgIndex}
|
||||
<div class="rounded border px-2 py-1 {isLatest && $streaming ? 'border-primary/30 bg-primary/5' : ''}">
|
||||
{#each group.entries as t (t.id)}
|
||||
<div class="flex items-start gap-1.5 {t.type === 'tool_result' && t.error ? 'text-destructive' : ''}">
|
||||
<span class="mt-0.5 shrink-0">
|
||||
{#if t.type === 'tool_result' && t.error}
|
||||
<CircleXIcon class="size-3 text-destructive" />
|
||||
{:else if t.type === 'tool_result'}
|
||||
<CircleCheckIcon class="size-3 text-success" />
|
||||
{:else}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-mono text-[10px] truncate">{toolSummary(t)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if digest && digest.total_executions > 0}
|
||||
<div class="border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
{#if open}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
|
||||
This session
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||||
{digest.total_executions} execution{digest.total_executions === 1 ? '' : 's'}
|
||||
{#if digest.knowledge_created.length}
|
||||
<span class="flex items-center gap-0.5 text-primary">
|
||||
<SparklesIcon class="size-3" />{digest.knowledge_created.length}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="flex flex-col gap-2 px-3 pb-3 text-xs">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each Object.entries(digest.by_status) as [status, count]}
|
||||
<Badge variant={statusVariant(status)}>{status} × {count}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if digest.knowledge_created.length}
|
||||
<div>
|
||||
<div class="mb-1 flex items-center gap-1 text-primary">
|
||||
<SparklesIcon class="size-3" />Learned this session
|
||||
</div>
|
||||
<ul class="list-inside list-disc text-muted-foreground">
|
||||
{#each digest.knowledge_created as title}
|
||||
<li>{title}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,90 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import * as Collapsible from '$lib/components/ui/collapsible'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import Spinner from './Spinner.svelte'
|
||||
|
||||
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
|
||||
|
||||
let open = $state(false)
|
||||
let wasActive = $state(active)
|
||||
|
||||
const bodyTools = $derived(unmatched ?? tools)
|
||||
const inlineCount = $derived(tools.length - bodyTools.length)
|
||||
|
||||
$effect(() => {
|
||||
if (active && !wasActive) open = true
|
||||
if (!active && wasActive) open = false
|
||||
wasActive = active
|
||||
})
|
||||
|
||||
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
|
||||
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
|
||||
const total = $derived(bodyTools.length)
|
||||
|
||||
const runningTool = $derived(
|
||||
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
|
||||
)
|
||||
|
||||
function toolLabel(t: ToolCallResult): string {
|
||||
if (!t.args || typeof t.args !== 'object') return t.name
|
||||
const firstArg = Object.values(t.args as Record<string, unknown>)[0]
|
||||
if (typeof firstArg === 'string' && firstArg.length < 50) return `${t.name} ${firstArg}`
|
||||
return t.name
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if bodyTools.length}
|
||||
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded border bg-card/50 text-xs">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-1.5 px-2 py-1 hover:bg-muted/30">
|
||||
<span class="shrink-0">
|
||||
{#if active && doneCount < total}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{:else if hasError}
|
||||
<XIcon class="size-3 text-destructive" aria-hidden="true" />
|
||||
{:else}
|
||||
<CheckIcon class="size-3 text-muted-foreground" aria-hidden="true" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="text-muted-foreground">
|
||||
{#if active && doneCount < total}
|
||||
{doneCount}/{total}
|
||||
{:else}
|
||||
{total} tool{total === 1 ? '' : 's'}
|
||||
{/if}
|
||||
{#if inlineCount > 0}
|
||||
<span> · {inlineCount} inline</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if active && runningTool}
|
||||
<span class="font-mono truncate">{toolLabel(runningTool)}<span class="animate-pulse">…</span></span>
|
||||
{/if}
|
||||
|
||||
<ChevronDownIcon
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 ml-auto {open ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1">
|
||||
<div class="flex flex-col divide-y border-t px-2 py-1">
|
||||
{#each bodyTools as tool (tool.id)}
|
||||
<div class="flex items-center gap-1.5 py-0.5">
|
||||
{#if tool.type === 'tool_result' && tool.error}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
{:else if tool.type === 'tool_result'}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
{:else}
|
||||
<Spinner class="size-3 shrink-0 text-primary" />
|
||||
{/if}
|
||||
<span class="font-mono text-[11px] truncate">{toolLabel(tool)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
@@ -1,70 +0,0 @@
|
||||
<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 { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const grouped = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
const g: Record<number, string[]> = {}
|
||||
for (const r of rows) {
|
||||
const d = Number(r.depth) || 0
|
||||
if (!g[d]) g[d] = []
|
||||
g[d].push(r.slug)
|
||||
}
|
||||
return g
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
const total = $derived(rows?.length ?? 0)
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="animate-pulse text-muted-foreground">calculating…</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" aria-label="Blast radius error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if grouped && total > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-y-auto divide-y">
|
||||
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
|
||||
<div class="px-3 py-2">
|
||||
<div class="mb-1 font-medium text-muted-foreground">
|
||||
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each slugs as slug}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Blast radius</span>
|
||||
<span class="text-muted-foreground">no affected entities found</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,92 +0,0 @@
|
||||
<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 { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
function shortTs(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
} catch {
|
||||
return ts?.slice(11, 19) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function shortDate(ts: string): string {
|
||||
return ts?.slice(0, 10) ?? ''
|
||||
}
|
||||
|
||||
function shortId(id: string): string {
|
||||
if (!id) return ''
|
||||
return id.length > 12 ? id.slice(0, 12) : id
|
||||
}
|
||||
|
||||
const activityRows = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
return rows.map((r) => ({
|
||||
time: shortTs(r.timestamp || r.ts || ''),
|
||||
date: shortDate(r.timestamp || r.ts || ''),
|
||||
actor: r.actor_label || shortId(r.agent_id) || r.actor_type || '',
|
||||
action: r.action || r.activity_type || '',
|
||||
toolName: r.tool_name || r.path || '',
|
||||
status: r.success ?? (r.error ? 'false' : undefined),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading activity log">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Activity log</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</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" aria-label="Activity log error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Activity log</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if activityRows && activityRows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Activity log: {activityRows.length} entries">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{activityRows.length} entr{activityRows.length === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-y-auto divide-y">
|
||||
{#each activityRows as row}
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 font-mono">
|
||||
<span class="shrink-0 text-muted-foreground">{row.time}</span>
|
||||
{#if row.date !== activityRows[0].date}
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground/60">{row.date}</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground">{row.action}</span>
|
||||
<span class="max-w-32 truncate">{row.toolName}</span>
|
||||
<span class="text-muted-foreground/60">{row.actor}</span>
|
||||
{#if row.status === 'true'}
|
||||
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-success" title="success"></span>
|
||||
{:else if row.status === 'false'}
|
||||
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-destructive" title="error"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Activity log: no entries">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Activity log</span>
|
||||
<span class="text-muted-foreground">no entries</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,118 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import BoxIcon from '@lucide/svelte/icons/box'
|
||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||
import ContainerIcon from '@lucide/svelte/icons/container'
|
||||
import GlobeIcon from '@lucide/svelte/icons/globe'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import ZapIcon from '@lucide/svelte/icons/zap'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const entity = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result') return null
|
||||
const r = tool.result
|
||||
if (!r) return null
|
||||
if (Array.isArray(r)) return r[0]
|
||||
if (r && typeof r === 'object' && 'data' in r) return Array.isArray(r.data) ? r.data[0] : r.data
|
||||
return r
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
const slug = $derived(entity?.slug ?? tool.args?.slug_or_id ?? tool.args?.hostname ?? tool.args?.service_slug ?? '')
|
||||
|
||||
const typeIcon: Record<string, typeof BoxIcon> = {
|
||||
host: MonitorIcon,
|
||||
lxc: ContainerIcon,
|
||||
service: GlobeIcon,
|
||||
check: ZapIcon,
|
||||
}
|
||||
|
||||
const Icon = $derived(entity?.type ? (typeIcon[entity.type] ?? BoxIcon) : BoxIcon)
|
||||
|
||||
const keyAttrs = $derived.by(() => {
|
||||
if (!entity) return [] as [string, string][]
|
||||
const out: [string, string][] = []
|
||||
const skip = new Set(['slug', 'type', 'name', 'state', 'health', 'last_check', 'version', 'created_at', 'updated_at', 'maintenance_until', '__renderer', 'data', 'attrs', 'attributes', 'enrolled_at'])
|
||||
for (const k of ['mesh_ip', 'ip', 'version', 'age_pubkey', 'enrolled_at', 'last_check']) {
|
||||
const v = entity[k]
|
||||
if (v && typeof v === 'string') {
|
||||
out.push([k, k === 'age_pubkey' ? v.slice(0, 16) + '…' : v])
|
||||
}
|
||||
}
|
||||
const attrs = entity.attributes ?? entity.attrs
|
||||
if (attrs && typeof attrs === 'object') {
|
||||
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
|
||||
if (!skip.has(k) && v != null && v !== '') {
|
||||
out.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.slice(0, 4)
|
||||
})
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
healthy: 'var(--success)',
|
||||
degraded: 'var(--warning)',
|
||||
down: 'var(--destructive)',
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entity: {slug || tool.name}">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</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" aria-label="Error loading entity: {error}">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if entity}
|
||||
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Entity: {entity.slug} — {entity.type} — {entity.health || 'no health data'}">
|
||||
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="font-mono font-semibold">{entity.slug ?? slug}</span>
|
||||
</div>
|
||||
{#if entity.type}
|
||||
<Badge variant="outline" class="text-[10px]">{entity.type}</Badge>
|
||||
{/if}
|
||||
{#if entity.state}
|
||||
<Badge variant="secondary" class="text-[10px]">{entity.state}</Badge>
|
||||
{/if}
|
||||
{#if entity.health && entity.health !== 'unknown'}
|
||||
<span class="flex items-center gap-1 text-muted-foreground">
|
||||
<span class="size-2 rounded-full" style="background: {healthColor[entity.health] ?? 'var(--muted-foreground)'}"></span>
|
||||
{entity.health}
|
||||
</span>
|
||||
{/if}
|
||||
{#if entity.last_check}
|
||||
<span class="text-muted-foreground">· {relativeTime(entity.last_check)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if keyAttrs.length > 0}
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||
{#each keyAttrs as [k, v]}
|
||||
<span class="font-mono text-[10px]"><span class="opacity-60">{k}:</span> {v}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Entity: {slug || tool.name} — no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,60 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
const cols = $derived(rows && rows.length > 0 ? Object.keys(rows[0]).filter(k => k !== '__renderer') : [])
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entities">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Entities</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</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" aria-label="Error loading entities">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Entities</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if rows && rows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Entities: {rows.length} results">
|
||||
<div class="max-h-56 overflow-y-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b text-muted-foreground">
|
||||
{#each cols as col}
|
||||
<th class="px-2 py-1 text-left font-medium whitespace-nowrap">{col}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row}
|
||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||
{#each cols as col}
|
||||
<td class="px-2 py-1 whitespace-nowrap font-mono max-w-48 truncate">{row[col] ?? '—'}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs text-muted-foreground" role="status" aria-label="Entities: no results">
|
||||
<XIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span>No entities found</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,108 +0,0 @@
|
||||
<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}
|
||||
@@ -1,93 +0,0 @@
|
||||
<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 { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const summary = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
const health: Record<string, number> = {}
|
||||
const types: Record<string, number> = {}
|
||||
for (const r of rows) {
|
||||
health[r.health || 'unknown'] = (health[r.health || 'unknown'] || 0) + 1
|
||||
types[r.type || 'unknown'] = (types[r.type || 'unknown'] || 0) + 1
|
||||
}
|
||||
return { health, types, total: rows.length }
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
healthy: 'var(--success)',
|
||||
degraded: 'var(--warning)',
|
||||
down: 'var(--destructive)',
|
||||
unknown: 'var(--muted-foreground)',
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading fleet snapshot">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Fleet snapshot</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</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" aria-label="Fleet snapshot error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Fleet snapshot</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if summary && summary.total > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Fleet snapshot: {summary.total} entities">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{summary.total} entities</span>
|
||||
</div>
|
||||
<div class="px-3 py-2 space-y-2">
|
||||
<!-- Health -->
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-1">Health</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
|
||||
{#if summary.health[h]}
|
||||
<div class="flex items-center gap-1.5 font-mono">
|
||||
<span class="size-1.5 rounded-full" style="background: {healthColor[h] ?? 'var(--muted-foreground)'}"></span>
|
||||
<span class="text-muted-foreground">{h}</span>
|
||||
<span class="tabular-nums">{summary.health[h]}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Types -->
|
||||
{#if Object.keys(summary.types).length > 0}
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-1">By type</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{#each Object.entries(summary.types).sort(([,a], [,b]) => b - a) as [type, count]}
|
||||
<div class="flex items-center gap-1.5 font-mono">
|
||||
<span class="text-muted-foreground">{type}</span>
|
||||
<span class="tabular-nums">{count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Fleet snapshot: no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Fleet snapshot</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,82 +0,0 @@
|
||||
<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 { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data : null
|
||||
})
|
||||
|
||||
const counts = $derived.by(() => {
|
||||
if (!rows) return null
|
||||
const m: Record<string, number> = {}
|
||||
for (const r of rows as any[]) m[r.health || 'unknown'] = (m[r.health || 'unknown'] || 0) + 1
|
||||
return m
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
const total = $derived(counts ? Object.values(counts).reduce((a, b) => a + b, 0) : 0)
|
||||
|
||||
const bars: { label: string; count: number; color: string }[] = [
|
||||
{ label: 'healthy', count: counts?.healthy ?? 0, color: 'var(--success)' },
|
||||
{ label: 'degraded', count: counts?.degraded ?? 0, color: 'var(--warning)' },
|
||||
{ label: 'down', count: counts?.down ?? 0, color: 'var(--destructive)' },
|
||||
{ label: 'unknown', count: counts?.unknown ?? 0, color: 'var(--muted-foreground)' },
|
||||
]
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading health summary">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Health summary</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</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" aria-label="Error loading health summary">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Health summary</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if counts && total > 0}
|
||||
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Health summary: {total} entities — healthy {counts?.healthy ?? 0}, degraded {counts?.degraded ?? 0}, down {counts?.down ?? 0}">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{total} entities</span>
|
||||
</div>
|
||||
<div class="flex h-5 w-full overflow-hidden rounded">
|
||||
{#each bars as bar}
|
||||
{#if bar.count > 0}
|
||||
<div
|
||||
style="width: {(bar.count / total) * 100}%; background: {bar.color}"
|
||||
class="flex items-center justify-center text-[9px] font-medium text-white min-w-[2rem]"
|
||||
title="{bar.label}: {bar.count}"
|
||||
>
|
||||
{bar.count}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-3 text-muted-foreground">
|
||||
{#each bars as bar}
|
||||
{#if bar.count > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="size-1.5 rounded-full" style="background: {bar.color}"></span>
|
||||
{bar.label} {bar.count}
|
||||
</span>
|
||||
{/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" aria-label="Health summary: no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Health summary</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,70 +0,0 @@
|
||||
<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'
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Searching knowledge">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Knowledge</span>
|
||||
<span class="animate-pulse text-muted-foreground">searching…</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" aria-label="Knowledge search error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Knowledge</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if rows && rows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Knowledge: {rows.length} results">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{rows.length} result{rows.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-y-auto divide-y">
|
||||
{#each rows as row}
|
||||
<div class="px-3 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<FileTextIcon class="size-3 shrink-0 mt-0.5 text-muted-foreground" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono font-medium truncate">{row.title}</div>
|
||||
{#if row.snippet || row.headline}
|
||||
<div class="mt-0.5 text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{row.snippet || row.headline}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
{#if row.source}
|
||||
<span>{row.source}</span>
|
||||
{/if}
|
||||
{#if row.slug}
|
||||
<span class="font-mono opacity-60">{row.slug}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Knowledge: no results">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Knowledge</span>
|
||||
<span class="text-muted-foreground">no results</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,85 +0,0 @@
|
||||
<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 { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
healthy: 'var(--success)',
|
||||
degraded: 'var(--warning)',
|
||||
down: 'var(--destructive)',
|
||||
}
|
||||
|
||||
function shortName(slug: string): string {
|
||||
return slug.split(':').pop() ?? slug
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading LXC containers">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">LXC containers</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</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" aria-label="Error loading LXC containers">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">LXC containers</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if rows && rows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="LXC containers: {rows.length} total">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{rows.length} container{rows.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b text-muted-foreground">
|
||||
<th class="px-3 py-1 text-left font-medium">Name</th>
|
||||
<th class="px-3 py-1 text-left font-medium">ID</th>
|
||||
<th class="px-3 py-1 text-left font-medium">IP</th>
|
||||
<th class="px-3 py-1 text-left font-medium">Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row}
|
||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||
<td class="px-3 py-1 font-mono">{shortName(row.slug)}</td>
|
||||
<td class="px-3 py-1 tabular-nums text-muted-foreground">{row.pve_id ?? '—'}</td>
|
||||
<td class="px-3 py-1 font-mono text-muted-foreground">{row.lan_ip ?? '—'}</td>
|
||||
<td class="px-3 py-1">
|
||||
{#if row.health}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="size-1.5 rounded-full" style="background: {healthColor[row.health] ?? 'var(--muted-foreground)'}"></span>
|
||||
{row.health}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="LXC containers: no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">LXC containers</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,68 +0,0 @@
|
||||
<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 { tool }: { tool: ToolCallResult } = $props()
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||
const data = (tool.result as any).data ?? tool.result
|
||||
return Array.isArray(data) ? data as any[] : null
|
||||
})
|
||||
|
||||
const loading = $derived(tool.type === 'tool_use')
|
||||
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading metrics">
|
||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">Metrics</span>
|
||||
<span class="animate-pulse text-muted-foreground">loading…</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" aria-label="Metrics error">
|
||||
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span class="font-medium">Metrics</span>
|
||||
<span class="text-destructive">{error}</span>
|
||||
</div>
|
||||
{:else if rows && rows.length > 0}
|
||||
<div class="rounded-lg border bg-card text-xs" aria-label="Metrics: {rows.length} samples">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">{rows.length} sample{rows.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b text-muted-foreground">
|
||||
<th class="px-2 py-1 text-left font-medium">Time</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Metric</th>
|
||||
<th class="px-2 py-1 text-right font-medium">Avg</th>
|
||||
<th class="px-2 py-1 text-right font-medium">Min</th>
|
||||
<th class="px-2 py-1 text-right font-medium">Max</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row}
|
||||
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||
<td class="px-2 py-1 font-mono tabular-nums whitespace-nowrap">{row.bucket?.slice(11, 16) ?? row.bucket?.slice(0, 19) ?? '—'}</td>
|
||||
<td class="px-2 py-1 font-mono max-w-32 truncate">{row.metric ?? '—'}</td>
|
||||
<td class="px-2 py-1 font-mono tabular-nums text-right">{row.avg ?? '—'}</td>
|
||||
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.min ?? '—'}</td>
|
||||
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.max ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Metrics: no data">
|
||||
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||
<span class="font-medium">Metrics</span>
|
||||
<span class="text-muted-foreground">no data</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import BlastRadius from './BlastRadius.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'get_blast_radius' || t.result?.__renderer === 'blast_radius',
|
||||
component: BlastRadius,
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import ChangeLog from './ChangeLog.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) =>
|
||||
t.name === 'get_change_history' ||
|
||||
t.name === 'get_agent_activity' ||
|
||||
t.result?.__renderer === 'change_log',
|
||||
component: ChangeLog,
|
||||
})
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import EntityCard from './EntityCard.svelte'
|
||||
|
||||
const TOOLS = ['get_entity', 'whoami', 'explain']
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => TOOLS.includes(t.name) || t.result?.__renderer === 'entity_card',
|
||||
component: EntityCard,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import EntityTable from './EntityTable.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'list_entities' || t.result?.__renderer === 'entity_table',
|
||||
component: EntityTable,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import ExecutionStatus from './ExecutionStatus.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'get_execution_status',
|
||||
component: ExecutionStatus,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import FleetSnapshot from './FleetSnapshot.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'get_state_snapshot' || t.result?.__renderer === 'fleet_snapshot',
|
||||
component: FleetSnapshot,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import HealthSummary from './HealthSummary.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'get_health_summary' || t.result?.__renderer === 'health_summary',
|
||||
component: HealthSummary,
|
||||
})
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { init as initEntityCard } from './entity-card'
|
||||
import { init as initHealthSummary } from './health-summary'
|
||||
import { init as initLXCList } from './lxc-list'
|
||||
import { init as initEntityTable } from './entity-table'
|
||||
import { init as initKnowledgeResults } from './knowledge-results'
|
||||
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()
|
||||
initLXCList()
|
||||
initEntityTable()
|
||||
initKnowledgeResults()
|
||||
initBlastRadius()
|
||||
initChangeLog()
|
||||
initFleetSnapshot()
|
||||
initMetricChart()
|
||||
initExecutionStatus()
|
||||
@@ -1,12 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import KnowledgeResults from './KnowledgeResults.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) =>
|
||||
t.name === 'search_knowledge' ||
|
||||
t.name === 'get_entity_knowledge' ||
|
||||
t.result?.__renderer === 'knowledge_results',
|
||||
component: KnowledgeResults,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import LXCList from './LXCList.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'list_lxcs' || t.result?.__renderer === 'lxc_list',
|
||||
component: LXCList,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||
import MetricChart from './MetricChart.svelte'
|
||||
|
||||
export function init() {
|
||||
registerToolRenderer({
|
||||
match: (t) => t.name === 'query_metrics' || t.result?.__renderer === 'metric_chart',
|
||||
component: MetricChart,
|
||||
})
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
|
||||
// which caused the AgentIndicator to latch onto a stale "Approval: ..."
|
||||
// entry and never clear — even after the session completed. Approvals
|
||||
// are tracked via the REST /approvals endpoint (context.ts, Ops.svelte)
|
||||
// and rendered as InlineApproval cards in the chat (or Ops page), not
|
||||
// and rendered as inline approval cards in the chat (or Ops page), not
|
||||
// in the activity log.
|
||||
|
||||
// Sort oldest first
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { fetchDashboardSummary, fetchApprovals, type DashboardSummary, type Approval } from '$lib/api'
|
||||
import { fetchDashboardSummary, fetchApprovals, type DashboardSummary } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents, type OikosEvent } from './events'
|
||||
|
||||
// Shared operational context: dashboard summary + pending approvals,
|
||||
@@ -7,7 +7,6 @@ import { liveEvents, subscribeEvents, type OikosEvent } from './events'
|
||||
// so the poll only runs while something on screen displays it.
|
||||
|
||||
export const summary = writable<DashboardSummary | null>(null)
|
||||
export const pendingApprovals = writable<Approval[]>([])
|
||||
|
||||
let refs = 0
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -16,9 +15,8 @@ let unsubscribeStore: (() => void) | null = null
|
||||
let lastSeenEventId = 0
|
||||
|
||||
export async function refreshContext() {
|
||||
const [s, approvals] = await Promise.all([fetchDashboardSummary(), fetchApprovals('pending')])
|
||||
const [s] = await Promise.all([fetchDashboardSummary(), fetchApprovals('pending')])
|
||||
if (s) summary.set(s)
|
||||
pendingApprovals.set(approvals)
|
||||
}
|
||||
|
||||
function onEvent(ev: OikosEvent) {
|
||||
|
||||
@@ -15,20 +15,16 @@ export interface OikosEvent {
|
||||
const MAX_BUFFERED = 200
|
||||
|
||||
export const liveEvents = writable<OikosEvent[]>([])
|
||||
export const connectionState = writable<'connecting' | 'open' | 'closed'>('connecting')
|
||||
|
||||
let source: EventSource | null = null
|
||||
let subscriberCount = 0
|
||||
|
||||
async function connect() {
|
||||
if (source) return
|
||||
connectionState.set('connecting')
|
||||
// The browser's EventSource sends Last-event-ID automatically on reconnect.
|
||||
// sseUrl is async so the OIDC access token is refreshed if expired.
|
||||
source = new EventSource(await sseUrl('/api/v1/events/stream'))
|
||||
|
||||
source.onopen = () => connectionState.set('open')
|
||||
|
||||
source.onmessage = (ev) => {
|
||||
try {
|
||||
const parsed: OikosEvent = JSON.parse(ev.data)
|
||||
@@ -39,7 +35,7 @@ async function connect() {
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
connectionState.set('closed')
|
||||
// browser will auto-reconnect; nothing to surface here
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
|
||||
const data = (ev.data ?? {}) as any
|
||||
|
||||
// Task fields (status/goal/outcome) live on the session row — refetch the
|
||||
// (cheap) session list so GoalHeader picks up the change without a
|
||||
// (cheap) session list so the UI picks up the change without a
|
||||
// dedicated endpoint. Every event that can change agent_sessions.status
|
||||
// (goal.set → planning, propose_plan → executing, ask_operator →
|
||||
// awaiting_input, answerQuestion → executing, complete_task → done/failed)
|
||||
@@ -138,8 +138,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
|
||||
}
|
||||
break
|
||||
case 'knowledge.recorded':
|
||||
// No dedicated store yet — the outcome/knowledge card reads this task's
|
||||
// digest (fetchSessionDigest) on completion, which already lists it.
|
||||
// No dedicated store — knowledge cards refetch on completion signal.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Component } from 'svelte'
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
|
||||
export interface ToolRenderer {
|
||||
match: (tool: ToolCallResult) => boolean
|
||||
component: Component<{ tool: ToolCallResult }>
|
||||
}
|
||||
|
||||
const registry: ToolRenderer[] = []
|
||||
|
||||
export function registerToolRenderer(r: ToolRenderer) {
|
||||
registry.push(r)
|
||||
}
|
||||
|
||||
export function getToolRenderer(tool: ToolCallResult): ToolRenderer | undefined {
|
||||
return registry.find((r) => r.match(tool))
|
||||
}
|
||||
@@ -22,8 +22,6 @@ function start() {
|
||||
initConfig()
|
||||
handleDesktopToken()
|
||||
|
||||
requestAnimationFrame(() => import('./lib/renderers'))
|
||||
|
||||
mount(App, { target: document.getElementById('app')! })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user