411 lines
12 KiB
Svelte
411 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
|
||
import { activityLog } from '$lib/stores/activity'
|
||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
||
import { Button } from '$lib/components/ui/button'
|
||
import { Textarea } from '$lib/components/ui/textarea'
|
||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||
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)
|
||
let scrolledUp = $state(false)
|
||
let container = $state<HTMLDivElement | null>(null)
|
||
|
||
function isNearBottom(): boolean {
|
||
if (!container) return true
|
||
const { scrollTop, scrollHeight, clientHeight } = container
|
||
return scrollHeight - scrollTop - clientHeight < 80
|
||
}
|
||
|
||
function onScroll() {
|
||
scrolledUp = !isNearBottom()
|
||
}
|
||
|
||
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
|
||
$effect(() => {
|
||
void $messages
|
||
if ($streaming || !scrolledUp) {
|
||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||
}
|
||
})
|
||
|
||
// Reset scroll lock when user sends a message.
|
||
function submitFollows() {
|
||
scrolledUp = false
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
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 = ''
|
||
scrolledUp = false
|
||
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">
|
||
<div class="flex min-w-0 flex-1 flex-col">
|
||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||
<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 user-msg">{msg.text}</div>
|
||
{:else}
|
||
<div class="flex w-full flex-col gap-2">
|
||
{#if msg.text}
|
||
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
|
||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||
{@html render(msg.text)}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
<AgentIndicator
|
||
active={$streaming || $activityLog.some((e) => e.status === 'running')}
|
||
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
|
||
error={$error}
|
||
/>
|
||
<div bind:this={messagesEnd}></div>
|
||
</div>
|
||
</div>
|
||
|
||
{#if $connectionState === 'disconnected'}
|
||
<div class="mx-auto w-full max-w-3xl px-4">
|
||
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
|
||
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
|
||
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
|
||
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={reconnect}>Reconnect</Button>
|
||
</div>
|
||
</div>
|
||
{:else if $connectionState === 'reconnecting'}
|
||
<div class="mx-auto w-full max-w-3xl px-4">
|
||
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
|
||
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
|
||
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#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}
|
||
|
||
{#each $chatErrors as err (err.id)}
|
||
<div class="mx-auto w-full max-w-3xl px-4">
|
||
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||
<span class="flex-1">{err.message}</span>
|
||
{#if err.action}
|
||
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => dismissError(err.id)}>{err.action}</Button>
|
||
{/if}
|
||
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => dismissError(err.id)} aria-label="Dismiss">×</button>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
|
||
<div class="border-t bg-card/50 p-3 input-ornament relative">
|
||
<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>
|
||
/* ── Art Nouveau chat styling ── */
|
||
|
||
/* Assistant message wrapper */
|
||
.assistant-msg {
|
||
position: relative;
|
||
}
|
||
|
||
/* User message — soft terracotta bubble, gentle lift */
|
||
.user-msg {
|
||
box-shadow: 0 1px 8px -4px var(--primary);
|
||
}
|
||
|
||
/* Prose overrides */
|
||
.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(ul) {
|
||
list-style-type: disc;
|
||
}
|
||
.prose-chat :global(ol) {
|
||
list-style-type: decimal;
|
||
}
|
||
.prose-chat :global(li) {
|
||
margin-bottom: 0.125rem;
|
||
padding-left: 0.25rem;
|
||
}
|
||
.prose-chat :global(li::marker) {
|
||
color: var(--primary);
|
||
}
|
||
|
||
.prose-chat :global(code) {
|
||
background: var(--muted);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 0.15em 0.4em;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.85em;
|
||
color: var(--primary);
|
||
}
|
||
|
||
.prose-chat :global(pre) {
|
||
background: var(--muted);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 0.75rem 0.875rem;
|
||
overflow-x: auto;
|
||
margin: 0 0 0.5rem;
|
||
position: relative;
|
||
}
|
||
.prose-chat :global(pre)::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||
opacity: 0.4;
|
||
}
|
||
.prose-chat :global(pre code) {
|
||
background: none;
|
||
padding: 0;
|
||
font-size: 0.8125rem;
|
||
color: inherit;
|
||
border: none;
|
||
}
|
||
|
||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||
margin separates sections; the first heading in a message doesn't. */
|
||
.prose-chat :global(h1),
|
||
.prose-chat :global(h2),
|
||
.prose-chat :global(h3) {
|
||
font-weight: 600;
|
||
margin: 1.15rem 0 0.4rem;
|
||
font-size: 1.03em;
|
||
letter-spacing: 0.01em;
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
.prose-chat :global(:first-child):is(h1, h2, h3) {
|
||
margin-top: 0;
|
||
}
|
||
.prose-chat :global(h1)::after,
|
||
.prose-chat :global(h2)::after,
|
||
.prose-chat :global(h3)::after {
|
||
content: '';
|
||
display: block;
|
||
width: 2.5rem;
|
||
height: 2px;
|
||
margin-top: 4px;
|
||
border-radius: 1px;
|
||
background: linear-gradient(to right, var(--primary), transparent);
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.prose-chat :global(table) {
|
||
border-collapse: collapse;
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.8125rem;
|
||
}
|
||
.prose-chat :global(th) {
|
||
background: var(--muted);
|
||
font-weight: 600;
|
||
}
|
||
.prose-chat :global(th),
|
||
.prose-chat :global(td) {
|
||
border: 1px solid var(--border);
|
||
padding: 0.3rem 0.6rem;
|
||
text-align: left;
|
||
}
|
||
|
||
.prose-chat :global(blockquote) {
|
||
border-left: 3px solid var(--primary);
|
||
padding-left: 0.75rem;
|
||
color: var(--muted-foreground);
|
||
margin: 0 0 0.5rem;
|
||
font-style: italic;
|
||
position: relative;
|
||
}
|
||
.prose-chat :global(blockquote)::before {
|
||
content: '“';
|
||
position: absolute;
|
||
left: -0.15rem;
|
||
top: -0.35rem;
|
||
font-size: 1.5rem;
|
||
color: var(--primary);
|
||
opacity: 0.6;
|
||
font-style: normal;
|
||
line-height: 1;
|
||
}
|
||
|
||
.prose-chat :global(hr) {
|
||
border: none;
|
||
height: 1px;
|
||
margin: 0.75rem 0;
|
||
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
|
||
}
|
||
|
||
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
||
terracotta accent stay meaningful (code, headings, links). */
|
||
.prose-chat :global(strong) {
|
||
color: var(--foreground);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.prose-chat :global(a) {
|
||
color: var(--primary);
|
||
text-decoration: underline;
|
||
text-decoration-style: dotted;
|
||
text-underline-offset: 2px;
|
||
}
|
||
|
||
/* Input area ornament */
|
||
.input-ornament::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 2rem;
|
||
right: 2rem;
|
||
height: 1px;
|
||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||
opacity: 0.3;
|
||
}
|
||
</style>
|