Root cause of "chat gave me no further feedback — had to go to Ops": two compounding bugs, found by reading the actual production session transcript. 1. InlineApproval.svelte — all of last session's live-status/self-heal work — was never imported or rendered anywhere. Chat.svelte had its own separate, much dumber approval bar (no status tracking, no destructive handling, just silently disappears after clicking) that WAS the one users actually saw. Deleted the dead bar and its state; InlineApproval now renders per-message. 2. chat.ts's extractApprovals hardcoded `tool.name === 'request_execution'`, so any approval raised by the newer `run` tool was invisible — no card, no feedback, nothing to self-heal, forcing the operator to the Ops page with zero acknowledgement in the conversation. This was the actual proximate cause of last night's destroy-135 session. Fixed to match on response shape, not tool name, so it doesn't silently break again for the next new gated tool. 3. Nomos was telling operators "type something like 'I confirm destroy 135'" for destructive actions (SOUL.md) but no backend path ever consumed that phrase — chat-assent explicitly (and correctly) excludes destructive from loose assent, but I never built the alternative. Added isTypedConfirmation() (cmd/nomos/assent.go): stricter than loose assent, requires an explicit "confirm" statement, only applies to destructive- flagged pending approvals. 4. InlineApproval's completed-state hardcoded "Provisioned successfully" — wrong/confusing for a destroy or arbitrary `run` command. Now says "Completed on <target>" and shows the actual command output, verified live against the real destroy-135 execution. Verified live in a real browser against the production API/DB (dev server proxying to :8090): the historical stuck session now retroactively renders both executions as resolved with correct wording and real output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
8.1 KiB
Svelte
259 lines
8.1 KiB
Svelte
<script lang="ts">
|
|
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
|
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'
|
|
import SquareIcon from '@lucide/svelte/icons/square'
|
|
import { marked } from 'marked'
|
|
import DOMPurify from 'dompurify'
|
|
|
|
let { showRail = true }: { showRail?: boolean } = $props()
|
|
|
|
let input = $state('')
|
|
let messagesEnd = $state<HTMLDivElement | null>(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}
|
|
{#if msg.pendingApprovals.length > 0}
|
|
<InlineApproval approvals={msg.pendingApprovals} />
|
|
{/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}
|
|
|
|
<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>
|