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:
2026-07-17 22:10:27 +02:00
parent c3973e7ac9
commit 0a3654b08f
35 changed files with 8 additions and 1736 deletions

View File

@@ -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}

View File

@@ -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}

View File

@@ -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} />

View File

@@ -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}

View File

@@ -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}