Files
oikos/web/src/pages/Chat.svelte
dtoro a567930466
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
fix: make execution names unique, move approval bar above input
- Execution entity name now includes UUID suffix: 'pct_create on host:strong (abc12345)'
  so the (type,name) UNIQUE constraint doesn't block subsequent executions for the
  same target+action. Dedup now uses JOIN + LIKE prefix match to find only
  pending_approval executions.

- Move persistent approval bar from top of messages area to just above the
  chat input box (bottom-fixed position, above the textarea form).
2026-07-09 23:42:49 +02:00

328 lines
11 KiB
Svelte

<script lang="ts">
import { messages, streaming, sendMessage, cancelStream, error, type PendingApproval } from '$lib/stores/chat'
import { decideApproval } from '$lib/api'
import SessionRail from '$lib/components/SessionRail.svelte'
import SessionGraph from '$lib/components/SessionGraph.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import SquareIcon from '@lucide/svelte/icons/square'
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'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
let { showRail = true }: { showRail?: boolean } = $props()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
let approving = $state<string | null>(null)
let approvedIds = $state(new Set<string>())
const pendingApprovals = $derived.by(() => {
const msgs = $messages
const all: PendingApproval[] = []
for (const m of msgs) {
all.push(...m.pendingApprovals)
}
return all.filter(a => !approvedIds.has(a.executionId))
})
async function approveAll() {
for (const a of pendingApprovals) {
approving = a.executionId
await decideApproval(a.executionId, 'approve')
approvedIds.add(a.executionId)
approvedIds = approvedIds
}
approving = null
}
async function approveOne(a: PendingApproval) {
approving = a.executionId
await decideApproval(a.executionId, 'approve')
approvedIds.add(a.executionId)
approvedIds = approvedIds
approving = null
}
async function denyOne(a: PendingApproval) {
approving = a.executionId
await decideApproval(a.executionId, 'deny')
approvedIds.add(a.executionId)
approvedIds = approvedIds
approving = null
}
// Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260
const RAIL_MAX = 620
function loadRailWidth(): number {
if (typeof localStorage === 'undefined') return 320
const v = Number(localStorage.getItem('oikos-rail-width'))
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
}
let railWidth = $state(loadRailWidth())
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
const startX = e.clientX
const startW = railWidth
function move(ev: PointerEvent) {
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
localStorage.setItem('oikos-rail-width', String(railWidth))
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
$effect(() => {
void $messages
void $streaming
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
})
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || $streaming) return
input = ''
sendMessage(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
const suggestions = [
'What needs my attention right now?',
'Summarize fleet health',
'Any pending approvals or open signals?',
'What changed in the last hour?'
]
function ask(q: string) {
if ($streaming) return
sendMessage(q)
}
</script>
<div class="flex h-full min-h-0">
{#if showRail}
<div class="hidden md:block">
<SessionRail />
</div>
{/if}
<div class="flex min-w-0 flex-1 flex-col">
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
{#if $messages.length === 0}
<div class="flex flex-col items-center gap-6 pt-24 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
</div>
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
{q}
</Button>
{/each}
</div>
</div>
{/if}
{#each $messages as msg, i (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
{#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 -->
{@html render(msg.text)}
</div>
{:else if msg.tools.length === 0}
<div class="flex items-center gap-1.5 py-1 text-sm text-muted-foreground">
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]"></span>
<span class="size-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]"></span>
<span class="size-1.5 animate-bounce rounded-full bg-current"></span>
</div>
{/if}
</div>
{/if}
</div>
{/each}
<div bind:this={messagesEnd}></div>
</div>
</div>
{#if $error}
<div class="mx-auto w-full max-w-3xl px-4">
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{$error}
</div>
</div>
{/if}
{#if pendingApprovals.length > 0}
<div class="shrink-0 border-t border-warning/30 bg-warning/5 px-4 py-2">
{#each pendingApprovals as a (a.executionId)}
<div class="flex items-center gap-2">
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
<span class="flex-1 text-xs font-medium">
{a.action} on {a.target}
</span>
{#if approving === a.executionId}
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
{:else}
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => approveOne(a)}>
<CheckIcon class="size-3" />
<span class="ml-1">Approve</span>
</Button>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => denyOne(a)}>
<XIcon class="size-3" />
<span class="ml-1">Deny</span>
</Button>
{/if}
</div>
{/each}
{#if pendingApprovals.length > 1}
<Button size="sm" variant="default" class="mt-1 h-6 px-2 text-xs" disabled={approving !== null} onclick={approveAll}>
<CheckIcon class="size-3" />
<span class="ml-1">Approve all</span>
</Button>
{/if}
</div>
{/if}
<div class="border-t bg-card/50 p-3">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
rows={2}
class="max-h-40 min-h-0 resize-none"
disabled={$streaming}
/>
{#if $streaming}
<Button type="button" size="icon" variant="destructive" onclick={cancelStream} aria-label="Stop">
<SquareIcon />
</Button>
{:else}
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
<ArrowUpIcon />
</Button>
{/if}
</form>
</div>
</div>
{#if showRail}
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize session graph"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="min-w-0 flex-1">
<SessionGraph />
</div>
</div>
{/if}
</div>
<style>
/* Minimal markdown styling for assistant messages. */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
}
.prose-chat :global(code) {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
</style>