Replaces the chat right rail's ad-hoc Digest+Graph stack with a single
TaskContextPanel that renders the task's live working state, driven by the
always-on events stream (not the per-turn chat SSE) so it keeps updating
during server-side auto-continuation/resume:
- GoalHeader: goal + status pill (planning/executing/awaiting_input/done/
failed), sourced from the sessions list.
- PlanProgress: ordered steps with live status icons + progress bar, hydrated
via new GET /sessions/{id}/plan; clicking a step with a target opens its
EntitySheet (no fake "jump to transcript" — bits-ui Collapsible content
isn't force-mounted, so a DOM-scroll jump would silently no-op for
collapsed tool groups).
- OperatorQuestion: the pinned structured question card (prompt/why/entity
chips/option buttons/free-text), hydrated via new GET /sessions/{id}/
questions; answering POSTs to the existing answer endpoint.
- SessionGraph upgraded to a live entity panel: entity.touched pulses the
node (animated ring) and shows "Now touching <slug>"; health.changed shows
a transient diff badge for touched entities.
- SessionDigest gains a success/failure/partial outcome banner and now also
refetches when the task's status changes, not just on session switch.
Two bugs found and fixed while wiring this up:
- workspace.ts's status-refresh trigger only covered goal.set/task.status;
question.raised/answered didn't refresh the sessions list, so GoalHeader's
pill went stale after answering via the panel (resumeSession runs entirely
server-side — no client 'done' event to piggyback a refresh on). Now every
status-affecting event triggers the (debounced) refetch.
- Forgot to rebuild the nomos container after adding the /plan and
/questions endpoints, so they silently fell through to the old default GET
handler — caught via a live curl diff against the running container,
not a code read.
Verified end-to-end against the live stack: goal/plan/question all update
without a reload as the agent works; answering a question via the panel
resumes the agent and the header pill correctly flips to Executing;
entity.touched pulses the live graph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
8.2 KiB
Svelte
259 lines
8.2 KiB
Svelte
<script lang="ts">
|
|
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
|
import SessionRail from '$lib/components/SessionRail.svelte'
|
|
import TaskContextPanel from '$lib/components/TaskContextPanel.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 task panel"
|
|
>
|
|
<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="flex min-w-0 flex-1 flex-col">
|
|
<TaskContextPanel />
|
|
</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>
|