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:
@@ -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}
|
||||
Reference in New Issue
Block a user