Replaces the read-only stats dashboard with a three-pane wiki: a navigator tree (group by folder/type/tag/entity), a reader/editor with bare-slug auto-linking and revision history + diff, and a context rail for backlinks and related notes. Adds a Cleanup mode for the drift tools (duplicates, tag manager, orphans, trash) and a Cmd+K quick-open. Also: - Adds a real landing view (hero count, KPI row, Nomos-share meter, recently-updated, busiest tags) in place of the old "Select a note" empty state, and extends the design pass across the tree/reader/rail (kind icons instead of repeated text badges, accent-bar selection, constrained prose measure). - Guards every note-selection path behind a confirm when there's an unsaved edit in progress, so switching notes can no longer silently discard a draft. - Extracts the markdown-rendering CSS duplicated across ChatThread, EntityDetailContent, and the new WikiReader into a shared .markdown-body class in app.css, with ChatThread keeping only its decorative deltas. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
634 lines
21 KiB
Svelte
634 lines
21 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 { 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, '&').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)
|
||
}
|
||
</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}
|
||
{@const isLast = idx === messages.length - 1}
|
||
{@const traceStatus = !isLast
|
||
? 'idle'
|
||
: error
|
||
? 'error'
|
||
: streaming
|
||
? '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={msg.tools}
|
||
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 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 — 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>
|