feat: pct_create action, ToolCallGroup collapse+animation, inline chat approval
- Add pct_create to request_execution (MCP) and executeApprovedAction (httpapi) Parses JSON config: vmid, hostname, cores, memory, disk_gb, ip, gw, storage, template, privileged, nesting, mounts, nameserver, searchdomain. Creates entity (state=provisioning), hosts relationship, entity_status on success. Fixes action string parsing to use Index instead of SplitN (colons in JSON). - Rewrite ToolCallGroup.svelte: bits-ui Collapsible replaces native <details>. Collapsed by default. Animated header shows live tool count + running tool name while streaming. Auto-expands during streaming, auto-collapses on done. - Add InlineApproval component: parses 'execution UUID queued' from agent response, renders Approve/Deny buttons inline in chat, calls decideApproval. - Document pct_create in nomos/SOUL.md with params, risk class, and approval flow. - Add session-review skill at .agents/skills/session-review/SKILL.md. - Add plan: 2026-07-09-session-execution-and-ux-fixes.md.
This commit is contained in:
67
web/src/lib/components/InlineApproval.svelte
Normal file
67
web/src/lib/components/InlineApproval.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { decideApproval } from '$lib/api'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
|
||||
let { text }: { text: string } = $props()
|
||||
|
||||
const RE = /\bexec[uecution]*\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i
|
||||
const match = $derived(text.match(RE))
|
||||
|
||||
let pending = $state(false)
|
||||
let done = $state<'approved' | 'denied' | null>(null)
|
||||
|
||||
async function approve() {
|
||||
if (!match) return
|
||||
pending = true
|
||||
const result = await decideApproval(match[1], 'approve')
|
||||
pending = false
|
||||
done = result ? 'approved' : 'denied'
|
||||
}
|
||||
|
||||
async function deny() {
|
||||
if (!match) return
|
||||
pending = true
|
||||
const result = await decideApproval(match[1], 'deny')
|
||||
pending = false
|
||||
done = result ? 'denied' : 'denied'
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
done = null
|
||||
pending = false
|
||||
void text
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if match && !done}
|
||||
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
||||
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
||||
<span class="flex-1 text-xs text-muted-foreground">This action requires approval</span>
|
||||
{#if pending}
|
||||
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={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={deny}>
|
||||
<XIcon class="size-3" />
|
||||
<span class="ml-1">Deny</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if done}
|
||||
<div class="my-2 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs {done === 'approved' ? 'border-success/40 bg-success/5 text-success' : 'border-destructive/40 bg-destructive/5 text-destructive'}">
|
||||
{#if done === 'approved'}
|
||||
<CheckIcon class="size-4" />
|
||||
<span>Approved. The action is running.</span>
|
||||
{:else}
|
||||
<XIcon class="size-4" />
|
||||
<span>Denied.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,22 +1,22 @@
|
||||
<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'
|
||||
|
||||
// active = this message is the one currently streaming a round of tool
|
||||
// calls. The group starts open while active (so progress is visible live)
|
||||
// and auto-collapses the moment that round finishes; a loaded/historical
|
||||
// message is never active, so it starts collapsed. Once the effect below
|
||||
// fires the one-time auto-collapse, manual toggles are left alone.
|
||||
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
|
||||
|
||||
let open = $state(active)
|
||||
let open = $state(false)
|
||||
let wasActive = active
|
||||
|
||||
$effect(() => {
|
||||
if (wasActive && !active) {
|
||||
if (active && !wasActive) {
|
||||
open = true
|
||||
}
|
||||
if (!active && wasActive) {
|
||||
open = false
|
||||
}
|
||||
wasActive = active
|
||||
@@ -24,9 +24,18 @@
|
||||
|
||||
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
|
||||
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
|
||||
const inProgress = $derived(active && doneCount < tools.length)
|
||||
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>)
|
||||
@@ -37,43 +46,63 @@
|
||||
</script>
|
||||
|
||||
{#if tools.length}
|
||||
<details bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
||||
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
|
||||
{#if inProgress}
|
||||
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
||||
<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}
|
||||
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
|
||||
<span class="max-w-64 truncate font-mono text-muted-foreground">{names}</span>
|
||||
<ChevronDownIcon class="size-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<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}
|
||||
<WrenchIcon class="size-3 shrink-0 animate-pulse 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>
|
||||
|
||||
{#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>
|
||||
<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>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||
import SessionGraph from '$lib/components/SessionGraph.svelte'
|
||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
@@ -113,6 +114,7 @@
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
|
||||
<InlineApproval text={msg.text} />
|
||||
{#if msg.text}
|
||||
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
|
||||
Reference in New Issue
Block a user