Files
oikos/web/src/lib/components/ToolCallGroup.svelte
dtoro d9683cfe29
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
fix: structured approvals + ToolCallGroup reactivity
- Replace text-based regex parsing in InlineApproval with structured
  pendingApprovals extracted from request_execution tool results. The tool
  result text is deterministic (not LLM-generated), making UUID extraction
  reliable regardless of how the LLM rephrases the response.

- Fix ToolCallGroup  reactivity: wasActive = active captured initial
  value. Now uses (active) so  re-runs on prop changes.

- Extract approvals in both live streaming (done event) and history loading
  for consistent behavior on resumed sessions.
2026-07-09 11:35:30 +02:00

109 lines
4.5 KiB
Svelte

<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import * as Collapsible from '$lib/components/ui/collapsible'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
let open = $state(false)
let wasActive = $state(active)
$effect(() => {
if (active && !wasActive) {
open = true
}
if (!active && wasActive) {
open = false
}
wasActive = active
})
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
const names = $derived(tools.map((t) => t.name).join(', '))
const runningTool = $derived(
active ? tools.find((t) => t.type === 'tool_use') : undefined
)
const ariaLabel = $derived(
doneCount === tools.length
? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed`
: `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done`
)
function toolSummary(args: unknown): string {
if (!args || typeof args !== 'object') return ''
return Object.entries(args as Record<string, unknown>)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ')
.slice(0, 80)
}
</script>
{#if tools.length}
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
{#if active && doneCount < tools.length}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
{:else if hasError}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else}
<CheckIcon class="size-3 shrink-0 text-success" />
{/if}
{#if active && doneCount < tools.length}
<span class="font-medium">{doneCount}/{tools.length}</span>
{#if runningTool}
<span class="max-w-48 truncate font-mono text-muted-foreground">
{runningTool.name}
<span class="animate-pulse"></span>
</span>
{:else}
<span class="animate-pulse text-muted-foreground">working…</span>
{/if}
{:else}
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
{/if}
<ChevronDownIcon
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 {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-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
<div class="flex flex-col divide-y border-t">
{#each tools as tool (tool.id)}
<div class="p-2">
<div class="flex items-center gap-2">
{#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}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
</div>
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
{#if tool.args}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
{/if}
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}