Files
oikos/web/src/pages/Chat.svelte
dtoro cc6bcdceaa
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
scroll fixes + activity bar position
- Activity bar moved to bottom of message list (before messagesEnd)
- Smart scroll: auto-scroll only during streaming or when near bottom
- Scrolling up pauses auto-scroll until next send
- Removed duplicate $effect block
- Plan: tool timeline in sidebar (plans/2026-07-14-tool-timeline-sidebar.md)
2026-07-14 11:37:52 +02:00

363 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
import { currentTask } from '$lib/stores/workspace'
import { resumeSession } from '$lib/api'
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 { getToolRenderer } from '$lib/tool-renderers'
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 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 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
}
const MAX_INLINE_CARDS = 5
function getInlineTools(msg: { tools: any[] }): any[] {
const matched = msg.tools.filter((t) => getToolRenderer(t))
if (matched.length <= MAX_INLINE_CARDS) return matched
return matched.slice(0, MAX_INLINE_CARDS)
}
function getRemaining(msg: { tools: any[] }, inline: any[]): any[] {
const inlineIds = new Set(inline.map((t) => t.id))
return msg.tools.filter((t) => !inlineIds.has(t.id))
}
// 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)
}
function statusLabel(s: string): string {
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
const liveStatus = $derived($currentTask?.status ?? ($currentSession ? 'active' : null))
</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" 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">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#each getInlineTools(msg) as tool (tool.id)}
{@const renderer = getToolRenderer(tool)}
{#if renderer}
<renderer.component {tool} />
{/if}
{/each}
<ToolCallGroup
tools={msg.tools}
unmatched={getRemaining(msg, getInlineTools(msg))}
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}
{#if $currentSession && $messages.length > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground py-1">
{#if $streaming}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
<span>Agent is responding…</span>
{:else if liveStatus === 'executing'}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-warning" />
<span>Working — {statusLabel(liveStatus)}</span>
<Button size="xs" variant="outline" class="ml-auto h-6 text-[11px]" onclick={async () => {
if ($currentSession) { await resumeSession($currentSession) }
}}>Continue</Button>
{:else if liveStatus === 'awaiting_input'}
<span class="text-warning">Waiting for your answer</span>
{:else if liveStatus}
<span>Status: {statusLabel(liveStatus)}</span>
{/if}
{#if $currentTask?.goal}
<span class="text-muted-foreground">· {$currentTask.goal.slice(0, 60)}{$currentTask.goal.length > 60 ? '…' : ''}</span>
{/if}
</div>
{/if}
<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">
<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>