Files
oikos/web/src/lib/components/ChatThread.svelte
dtoro dffe01fb02
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
fix(web): use the terracotta accent instead of green for done checkmarks
text-primary instead of text-success — keeps the "done" state on-brand
with the rest of the UI (buttons, focus rings) rather than introducing
a separate green that only really worked well on the dark theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 12:12:00 +02:00

595 lines
20 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">
// Pure prop-driven transcript + input — no store imports. Both the main
// Chat page (singleton "current session" stores) and a floating task
// window (its own per-session store bundle from chat.ts's chatFor) render
// through this, so the message-bubble/markdown styling lives in one place
// instead of being copy-pasted between the two.
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import type { Readable } from 'svelte/store'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import Spinner from './Spinner.svelte'
import ToolCallCard from './ToolCallCard.svelte'
import OperatorQuestion from './OperatorQuestion.svelte'
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
import type { SessionQuestion } from '$lib/api'
let {
messages,
streaming,
connectionState,
error = null,
chatErrors = [],
onSend,
onCancel,
onReconnect,
onDismissError,
suggestions = [],
activityLog: activityLogProp = activityLog,
sessionId = null,
question = null
}: {
messages: ChatMessage[]
streaming: boolean
connectionState: 'connected' | 'disconnected' | 'reconnecting'
error?: string | null
chatErrors?: { id: string; message: string; action?: string }[]
onSend: (text: string) => void
onCancel: () => void
onReconnect: () => void
onDismissError: (id: string) => void
suggestions?: string[]
activityLog?: Readable<ActivityEntry[]>
/** Session this thread's pending question (below) should post its answer against — see OperatorQuestion.svelte. */
sessionId?: string | null
/** The session's open operator question, if any — rendered as an inline card at the end of the thread (the newest thing, blocking the agent until answered). */
question?: SessionQuestion | null
} = $props()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
let indicatorDone = $state(false)
let wasStreaming = $state(false)
$effect(() => {
if (streaming) { indicatorDone = false; wasStreaming = true }
if (!streaming && wasStreaming) {
indicatorDone = true
const t = setTimeout(() => { indicatorDone = false; wasStreaming = false }, 3000)
return () => clearTimeout(t)
}
})
const indicatorLabel = $derived.by(() => {
if (error) return error
if (!streaming && indicatorDone) return 'Done'
const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
if (running) return running.description
return 'Agent is thinking…'
})
// Resizable input area — drag the splitter above it to grow the textarea,
// capped so it can't swallow the whole thread. Both the minimum and the
// default are exactly one line: measured from the textarea's own
// line-height/padding/border rather than hardcoded, so it stays correct if
// that styling ever changes.
let threadHeight = $state(0)
let textareaRef = $state<HTMLTextAreaElement | null>(null)
let inputWrapperRef = $state<HTMLDivElement | null>(null)
let oneLinePx = $state(64)
$effect(() => {
if (!textareaRef || !inputWrapperRef) return
const taCs = getComputedStyle(textareaRef)
const lineHeight = parseFloat(taCs.lineHeight)
if (!Number.isFinite(lineHeight)) return
const taBoxY =
parseFloat(taCs.paddingTop) + parseFloat(taCs.paddingBottom) + parseFloat(taCs.borderTopWidth) + parseFloat(taCs.borderBottomWidth)
// The wrapper's own padding/border (space around the textarea, not part
// of it) also has to fit inside the minimum, or the textarea gets
// squeezed below one line once the pane is dragged down to it.
const wrapperCs = getComputedStyle(inputWrapperRef)
const wrapperBoxY =
parseFloat(wrapperCs.paddingTop) +
parseFloat(wrapperCs.paddingBottom) +
parseFloat(wrapperCs.borderTopWidth) +
parseFloat(wrapperCs.borderBottomWidth)
oneLinePx = lineHeight + taBoxY + wrapperBoxY
})
const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12)
// Keep the input pinned to inputMinSize (one line) until the user actually
// drags the splitter — not just on the first measurement. A floating
// window's threadHeight is 0/wrong for a frame or two while it animates
// open, and locking the percentage to that first reading left the input
// several lines tall once the window reached full size (fixed 2026-07-21).
let inputSize = $state(12)
let userResizedInput = false
$effect(() => {
if (!userResizedInput) inputSize = inputMinSize
})
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 (or a freshly-raised question) —
// unless user scrolled up to read.
$effect(() => {
void messages
void question
if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
})
function render(text: string): string {
const renderer = new marked.Renderer()
renderer.code = function ({ text, lang }) {
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<div class="code-block-wrapper relative group"><pre><code class="language-${lang || 'plaintext'}">${escaped}</code></pre><button class="code-copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)" title="Copy" aria-label="Copy code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>`
}
renderer.table = function (token) {
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
const body = token.rows.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`).join('')
return `<div class="table-wrapper"><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`
}
return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
}
function formatTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} catch {
return ''
}
}
function submit() {
const text = input.trim()
if (!text || streaming) return
input = ''
scrolledUp = false
onSend(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
function ask(q: string) {
if (streaming) return
onSend(q)
}
</script>
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1" on:resize={() => (userResizedInput = true)}>
<Pane class="flex flex-col">
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
{#if messages.length === 0}
<div class="flex flex-1 flex-col items-center justify-center gap-6 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>
{#if suggestions.length}
<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>
{/if}
</div>
{/if}
{#each messages as msg, idx (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
{/if}
</div>
<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">
<div class="flex items-baseline gap-2 px-1">
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
{#if msg.created_at}
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
{/if}
</div>
{#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)}
{#if idx === messages.length - 1 && streaming}
<span class="stream-cursor" aria-hidden="true"></span>
{/if}
</div>
{/if}
{#if msg.tools.length > 0}
<div class="flex flex-col gap-1.5">
{#each msg.tools as tool (tool.id)}
<ToolCallCard {tool} />
{/each}
</div>
{/if}
{#if idx === messages.length - 1 && msg.text === '' && (streaming || indicatorDone || error)}
<div class="flex items-center gap-2 py-1 text-xs {error ? 'text-destructive' : indicatorDone ? 'text-primary' : 'text-muted-foreground'}">
{#if error}
<XIcon class="size-3 shrink-0" />
{:else if indicatorDone}
<CheckIcon class="size-3 shrink-0" />
{:else}
<Spinner class="size-3 shrink-0 text-primary" />
{/if}
<span>{indicatorLabel}</span>
</div>
{/if}
</div>
{/if}
</div>
{/each}
{#if question}
<OperatorQuestion {sessionId} {question} />
{/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={onReconnect}>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={() => onDismissError(err.id)}>{err.action}</Button>
{/if}
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => onDismissError(err.id)} aria-label="Dismiss">×</button>
</div>
</div>
{/each}
</Pane>
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
<div class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative" bind:this={inputWrapperRef}>
<form
class="relative mx-auto flex h-full w-full max-w-3xl"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:ref={textareaRef}
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
disabled={streaming}
/>
{#if streaming}
<Button
type="button"
size="icon-sm"
variant="secondary"
class="absolute right-2 bottom-2 rounded-lg"
onclick={onCancel}
aria-label="Stop"
>
<SquareIcon class="size-3.5" />
</Button>
{:else}
<Button
type="submit"
size="icon-sm"
variant="secondary"
class="absolute right-2 bottom-2 rounded-lg"
disabled={!input.trim()}
aria-label="Send"
>
<CornerDownLeftIcon class="size-3.5" />
</Button>
{/if}
</form>
</div>
</Pane>
</Splitpanes>
</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);
overflow-wrap: break-word;
}
/* 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) {
font-size: 1.15em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h2) {
font-size: 1.08em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h3) {
font-size: 1.02em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(> h1:first-child),
.prose-chat :global(> h2:first-child),
.prose-chat :global(> h3:first-child) {
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(.table-wrapper) {
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(.table-wrapper table) {
margin: 0;
}
.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;
}
/* Code copy button — global: injected via render() into {html} blocks */
.prose-chat :global(.code-block-wrapper) {
position: relative;
}
.prose-chat :global(.code-copy-btn) {
position: absolute;
top: 0.375rem;
right: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
color: var(--muted-foreground);
opacity: 0;
transition: opacity 0.15s, color 0.15s;
cursor: pointer;
border: none;
background: transparent;
}
.prose-chat :global(.code-block-wrapper:hover .code-copy-btn) {
opacity: 1;
}
.prose-chat :global(.code-copy-btn:hover) {
color: var(--foreground);
background: var(--muted);
}
/* Streaming cursor — blinking block appended after streaming text */
.stream-cursor {
display: inline-block;
width: 0.55em;
height: 1.1em;
background: var(--primary);
opacity: 0.75;
border-radius: 1px;
margin-left: 1px;
vertical-align: text-bottom;
animation: cursor-blink 0.9s ease-in-out infinite;
}
@keyframes cursor-blink {
0%, 100% { opacity: 0.75; }
50% { opacity: 0; }
}
</style>