Make background/long/desynced turns visible and queueable, fixing the four
symptoms that survived the v0.15.0 chat reliability pass.
F1 - status-driven working signal (workspace.ts taskWorking/currentWorking =
streaming OR status in {planning,executing}). Drives the chat trace, indicator,
and activity spinner so a turn with no live stream (background resume, a dropped
SSE, an idle-close mid long turn) still looks alive.
F2 - operator messages sent during an in-flight turn are now QUEUED and
auto-run when the gate frees, replacing the "still finishing a previous step...
send it again" rejection. Per-session in-memory FIFO (messagequeue.go, capped at
20) drained one-at-a-time under the turn gate; a `queued` SSE event drives a
"Queued" hint. drainQueued releases via a per-iteration deferred closure so a
runChatTurn panic can't deadlock the session's gate.
F3 - SSE keepalive (12s `:keepalive` comment) in handleChat so 20-40s
inter-iteration gaps no longer trip a proxy/browser idle close (the desync root
cause). All SSE writes serialized through one mutex.
F4 - generation-aware activity timeline (only the last propose_plan renders;
superseded ones collapse to one "Earlier plan revised" marker; step-attribution
follows only the current generation) + debounced plan refetch on lifecycle
events so a missed plan.proposed self-heals.
Verified against the last session (23da10db: 6m33s turn, operator "status"
deferred at 19:48:05). go test ./cmd/nomos/ green (new messagequeue tests);
web vitest 72/72 (new F4 generation tests); vite build clean.
VERSION: 0.16.0 -> 0.17.0
697 lines
25 KiB
Svelte
697 lines
25 KiB
Svelte
<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 AgentTrace from './AgentTrace.svelte'
|
||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||
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 { ToolCallResult } from '$lib/types'
|
||
import type { SessionQuestion } from '$lib/api'
|
||
|
||
let {
|
||
messages,
|
||
streaming,
|
||
connectionState,
|
||
working = false,
|
||
error = null,
|
||
chatErrors = [],
|
||
onSend,
|
||
onCancel,
|
||
onReconnect,
|
||
onDismissError,
|
||
suggestions = [],
|
||
activityLog: activityLogProp = activityLog,
|
||
sessionId = null,
|
||
question = null,
|
||
initialDraft = ''
|
||
}: {
|
||
messages: ChatMessage[]
|
||
streaming: boolean
|
||
connectionState: 'connected' | 'disconnected' | 'reconnecting'
|
||
/** True while a turn is running for this session — a live stream OR the
|
||
* server-side status says planning/executing. Drives the "working"
|
||
* indicator so a background/long/desynced turn still looks alive. The
|
||
* literal `streaming` (live deltas) is still used for the cursor + input
|
||
* lock. See plan 2026-08-03 F1. */
|
||
working?: boolean
|
||
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
|
||
/** Pre-fills the composer. Used by "Ask Nomos" in the entity window so an
|
||
* investigation starts from what the operator was just looking at, rather
|
||
* than making them retype it. Left editable on purpose — it is a starting
|
||
* point, not a command. */
|
||
initialDraft?: string
|
||
} = $props()
|
||
|
||
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
|
||
let scrolledUp = $state(false)
|
||
let container = $state<HTMLDivElement | null>(null)
|
||
|
||
let indicatorDone = $state(false)
|
||
let wasWorking = $state(false)
|
||
|
||
$effect(() => {
|
||
if (working) {
|
||
indicatorDone = false
|
||
wasWorking = true
|
||
}
|
||
if (!working && wasWorking) {
|
||
indicatorDone = true
|
||
const t = setTimeout(() => {
|
||
indicatorDone = false
|
||
wasWorking = false
|
||
}, 3000)
|
||
return () => clearTimeout(t)
|
||
}
|
||
})
|
||
|
||
const indicatorLabel = $derived.by(() => {
|
||
if (error) return error
|
||
if (!working && indicatorDone) return 'Done'
|
||
// Prefer the running PLAN STEP as the headline — it's stable across the
|
||
// step's many tool calls, so the line stops rewriting itself on every
|
||
// command (the "thinking overwrites itself" complaint, F6). Falls back to
|
||
// the current tool only when there's no active step (a plan-less Q&A or
|
||
// between steps), and to a plain "thinking…" otherwise.
|
||
const runningStep = $activityLogProp.find((e: ActivityEntry) => e.type === 'step_running')
|
||
if (runningStep) return runningStep.description
|
||
const runningTool = $activityLogProp.find((e: ActivityEntry) => e.type === 'tool_running')
|
||
if (runningTool) return runningTool.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. Sets scrollTop on the messages container
|
||
// directly instead of `scrollIntoView`, which walks ancestors and forces a
|
||
// reflow that can momentarily perturb the window titlebar height.
|
||
$effect(() => {
|
||
void messages
|
||
void question
|
||
if (streaming || !scrolledUp) {
|
||
setTimeout(
|
||
() => container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }),
|
||
50
|
||
)
|
||
}
|
||
})
|
||
|
||
function render(text: string): string {
|
||
const renderer = new marked.Renderer()
|
||
renderer.code = function ({ text, lang }) {
|
||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
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)
|
||
}
|
||
|
||
// Merge live streaming output (from the activity log's `run` entry) onto the
|
||
// in-flight turn's tool calls so the inline tool card shows command output as
|
||
// it arrives — the place the operator naturally "checks the tool". Only the
|
||
// last assistant message can be streaming, so only it gets enriched; history
|
||
// is untouched (and has no live output anyway). (F4)
|
||
function toolsWithLive(
|
||
tools: ToolCallResult[],
|
||
entries: ActivityEntry[],
|
||
isLiveTurn: boolean
|
||
): ToolCallResult[] {
|
||
if (!isLiveTurn) return tools
|
||
const liveById = new Map<string, string>()
|
||
for (const e of entries) {
|
||
if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
|
||
}
|
||
if (liveById.size === 0) return tools
|
||
return tools.map((t) =>
|
||
t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
|
||
)
|
||
}
|
||
</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>
|
||
{#if idx === messages.length - 1 && working && !streaming}
|
||
<!-- The last message is this user bubble and the agent is
|
||
working but not live-streaming → the message was queued
|
||
behind an in-flight turn (plan 2026-08-03 F2). It'll run
|
||
when the current step finishes. -->
|
||
<span class="px-1 text-[10px] text-muted-foreground"
|
||
>Queued — Nomos will run this when it finishes the current step.</span
|
||
>
|
||
{/if}
|
||
{:else}
|
||
{@const isLast = idx === messages.length - 1}
|
||
{@const traceStatus = !isLast
|
||
? 'idle'
|
||
: error
|
||
? 'error'
|
||
: working
|
||
? 'running'
|
||
: indicatorDone
|
||
? 'done'
|
||
: 'idle'}
|
||
<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>
|
||
<!-- The working trace sits above the answer: it's what happened
|
||
first, and collapsed it keeps a long tool run from burying
|
||
the text below it. -->
|
||
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
|
||
<AgentTrace
|
||
tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
|
||
status={traceStatus}
|
||
label={traceStatus === 'idle' ? null : indicatorLabel}
|
||
/>
|
||
{/if}
|
||
{#if msg.text}
|
||
<div
|
||
class="markdown-body 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 isLast && streaming}
|
||
<span class="stream-cursor" aria-hidden="true"></span>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
{#if question}
|
||
<OperatorQuestion {sessionId} {question} />
|
||
{/if}
|
||
</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 animate-spin text-warning" aria-hidden="true" />
|
||
<span class="text-warning-foreground flex-1"
|
||
>Connection dropped — the task is still running and will catch up here automatically.
|
||
Reconnect to refresh now.</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}
|
||
>
|
||
{#if working && !streaming}
|
||
<!-- Background/autonomous turn in progress (no live stream to watch):
|
||
keep the composer open so the operator can queue a follow-up
|
||
(plan 2026-08-03 F1/F2). -->
|
||
<div class="mb-1 px-1 text-[10px] text-muted-foreground">
|
||
Nomos is working in the background — your message will queue and run when it's free.
|
||
</div>
|
||
{/if}
|
||
<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 — deltas on top of the shared .markdown-body base
|
||
(app.css) only. The template applies both classes together
|
||
(class="markdown-body prose-chat ..."); everything below either adds a
|
||
look .markdown-body doesn't have (li::marker, the pre/blockquote
|
||
::before ornaments, hr, strong, the table-wrapper, the code-copy
|
||
button) or overrides a .markdown-body value that this "Art Nouveau"
|
||
chat treatment wants different (code/pre padding, heading size, th/td
|
||
padding, blockquote border color, link underline style). Anywhere a
|
||
value is actually overridden, the selector is
|
||
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
|
||
:global() selectors from two different <style> blocks land in the same
|
||
stylesheet with no scoping to arbitrate between them, so equal
|
||
specificity would leave the winner to injection order (unreliable
|
||
across dev/build). The two-class selector's higher specificity wins
|
||
deterministically regardless. */
|
||
.prose-chat :global(li) {
|
||
padding-left: 0.25rem;
|
||
}
|
||
.prose-chat :global(li::marker) {
|
||
color: var(--primary);
|
||
}
|
||
|
||
.markdown-body.prose-chat :global(code) {
|
||
border: 1px solid var(--border);
|
||
padding: 0.15em 0.4em;
|
||
color: var(--primary);
|
||
}
|
||
|
||
.markdown-body.prose-chat :global(pre) {
|
||
padding: 0.75rem 0.875rem;
|
||
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) {
|
||
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. */
|
||
.markdown-body.prose-chat :global(h1) {
|
||
font-size: 1.15em;
|
||
margin: 1.15rem 0 0.4rem;
|
||
letter-spacing: 0.01em;
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
.markdown-body.prose-chat :global(h2) {
|
||
font-size: 1.08em;
|
||
margin: 1.15rem 0 0.4rem;
|
||
letter-spacing: 0.01em;
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
.markdown-body.prose-chat :global(h3) {
|
||
font-size: 1.02em;
|
||
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-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;
|
||
}
|
||
.markdown-body.prose-chat :global(th),
|
||
.markdown-body.prose-chat :global(td) {
|
||
padding: 0.3rem 0.6rem;
|
||
}
|
||
|
||
.markdown-body.prose-chat :global(blockquote) {
|
||
border-left: 3px solid var(--primary);
|
||
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;
|
||
}
|
||
|
||
.markdown-body.prose-chat :global(a) {
|
||
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>
|