<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 { resumeSession } from '$lib/api'
  import type { Readable } from 'svelte/store'
  import { Button } from '$lib/components/ui/button'
  import { Textarea } from '$lib/components/ui/textarea'
  import TurnTrace from './TurnTrace.svelte'
  import ThinkingBlock from './ThinkingBlock.svelte'
  import OperatorQuestion from './OperatorQuestion.svelte'
  import GlyphIndicator from './GlyphIndicator.svelte'
  import Spinner from './Spinner.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 CopyIcon from '@lucide/svelte/icons/copy'
  import CheckIcon from '@lucide/svelte/icons/check'
  import ArrowDownToLineIcon from '@lucide/svelte/icons/arrow-down-to-line'
  import { marked } from 'marked'
  import DOMPurify from 'dompurify'
  import type { ChatMessage } from '$lib/stores/chat'
  import type { ToolCallResult } from '$lib/types'
  import type { PlanStep, 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 = '',
    planSteps = [],
    taskStatus,
    lastActiveAt
  }: {
    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
    /** Current-generation plan steps for this session — rendered as a live
     *  checklist on the running turn (TodoWrite-style). Empty for a new/plan-less
     *  task and for the new-task launcher. */
    planSteps?: PlanStep[]
    /** Session status (active/planning/executing/…/done/failed). Drives the
     *  plan checklist's collapse-to-summary at a terminal state. */
    taskStatus?: string
    /** Session's last_active_at timestamp — used to detect a stuck turn
     *  (working but no activity for >5 min) and show elapsed time. */
    lastActiveAt?: 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…'
  })

  // ── stuck detection + elapsed time ─────────────────────────────────────
  // A turn is "stuck" when the server says working (planning/executing) but
  // last_active_at is >5 min old — the agent's turn ended without updating
  // the session status (crash, timeout, or a zombie gate). Show a distinct
  // stuck indicator with a Resume button instead of a misleading "working…".
  let resuming = $state(false)
  let now = $state(Date.now())

  $effect(() => {
    if (!working) return
    const id = setInterval(() => {
      now = Date.now()
    }, 1000)
    return () => clearInterval(id)
  })

  const elapsedSeconds = $derived(
    working && lastActiveAt
      ? Math.max(0, Math.floor((now - new Date(lastActiveAt).getTime()) / 1000))
      : 0
  )
  const isStuck = $derived(working && !streaming && elapsedSeconds > 300)

  function formatElapsed(s: number): string {
    if (s < 60) return `${s}s`
    if (s < 3600) return `${Math.floor(s / 60)}m`
    return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
  }

  async function handleResume() {
    if (!sessionId || resuming) return
    resuming = true
    try {
      await resumeSession(sessionId)
    } finally {
      resuming = false
    }
  }

  // ── glyph backdrop ─────────────────────────────────────────────────────
  // The agent's live semantic state, rendered as a faint procedural glyph
  // behind the transcript. Computed from the same signals as the sidebar
  // (status / working / streaming / connection / stuck) so the backdrop
  // breathes with the agent without any store imports (prop-driven).
  const agentSprite = $derived.by(() => {
    if (connectionState !== 'connected') return 'status.offline'
    if (taskStatus === 'failed') return 'status.error'
    if (taskStatus === 'abandoned') return 'status.cancelled'
    if (taskStatus === 'done') return 'status.success'
    if (taskStatus === 'awaiting_input') return 'ai.listening'
    if (isStuck) return 'status.warning'
    if (streaming) return 'ai.speaking'
    if (working) return 'ai.still-working'
    if (taskStatus === 'planning') return 'ai.thinking'
    return 'ai.idle'
  })

  // 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.
  //
  // During streaming, scroll INSTANTLY (behavior: 'auto') — the content is
  // growing continuously, so a smooth animation constantly chases a moving
  // target and produces the jerky "jumping" the operator sees. For
  // non-streaming updates (a completed message, a question), a smooth scroll
  // is fine. Uses requestAnimationFrame so the scroll lands after the DOM
  // update, not 50ms later.
  $effect(() => {
    void messages
    void question
    if (streaming || !scrolledUp) {
      const behavior = streaming ? ('auto' as const) : ('smooth' as const)
      requestAnimationFrame(() => {
        container?.scrollTo({ top: container.scrollHeight, behavior })
      })
    }
  })

  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)
  }

  // Per-message copy affordance (border-driven icon button on each row).
  let copiedId = $state<string | null>(null)
  async function copyMessage(msg: ChatMessage) {
    try {
      await navigator.clipboard.writeText(msg.text)
      copiedId = msg.id
      setTimeout(() => {
        if (copiedId === msg.id) copiedId = null
      }, 1400)
    } catch {
      /* clipboard unavailable — silently no-op */
    }
  }

  // Scroll-to-bottom: uses container.scrollTo (never scrollIntoView, which
  // reflows ancestor wmkit panes — see wmkit.scrollintoview_reflow_pitfall).
  function jumpToBottom() {
    container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
    scrolledUp = false
  }

  // Enrich a turn's tool calls with live `run` output AND plan-step
  // attribution pulled from the activity log (keyed by tool id), so the inline
  // TurnTrace can pin streaming output to its tool and group calls under their
  // step. Run for every turn (not just the live one) so historical turns group
  // correctly too; unmapped tools pass through unchanged.
  function enrichTools(tools: ToolCallResult[], entries: ActivityEntry[]): ToolCallResult[] {
    const byId = new Map<string, { liveOutput?: string; stepSeq?: number }>()
    for (const e of entries) {
      if (!e.id) continue
      const cur = byId.get(e.id) ?? {}
      if (e.liveOutput) cur.liveOutput = e.liveOutput
      if (e.stepSeq != null) cur.stepSeq = e.stepSeq
      byId.set(e.id, cur)
    }
    if (byId.size === 0) return tools
    return tools.map((t) => {
      if (!t.id) return t
      const e = byId.get(t.id)
      if (!e) return t
      const next: ToolCallResult = { ...t }
      if (e.liveOutput) next.liveOutput = e.liveOutput
      if (e.stepSeq != null) next.stepSeq = e.stepSeq
      return next
    })
  }
</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="relative min-h-0 flex-1">
        <!-- Glyph backdrop — the agent's live semantic state as a faint
             procedural watermark behind the transcript. Fixed (doesn't scroll
             with the messages), pointer-events none, behind the content. -->
        <div class="glyph-backdrop" aria-hidden="true">
          <GlyphIndicator sprite={agentSprite} seed={sessionId ?? 'oikos'} size={640} opacity={0.07} />
        </div>
        <div class="relative z-[1] h-full overflow-y-auto" bind:this={container} onscroll={onScroll}>
          <div class="mx-auto flex min-h-full max-w-3xl flex-col divide-y divide-border px-4">
            {#if messages.length === 0}
              <div class="flex flex-1 flex-col items-center justify-center gap-6 p-8 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)}
              {@const isLast = idx === messages.length - 1}
              <div class="msg-row relative flex gap-3 py-3">
                <div class="msg-role" aria-hidden="true">{msg.role === 'user' ? 'YOU' : 'NOMOS'}</div>
                <div class="msg-body min-w-0 flex-1">
                  {#if msg.role === 'user'}
                    <div class="user-text whitespace-pre-wrap text-sm leading-relaxed">{msg.text}</div>
                    {#if msg.created_at}
                      <div class="msg-time">{formatTime(msg.created_at)}</div>
                    {/if}
                    {#if isLast && working && !streaming}
                      <!-- The last message is this user row 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. -->
                      <div class="queued-hint">Queued — runs when Nomos finishes the current step.</div>
                    {/if}
                  {:else}
                    {@const traceStatus = !isLast
                      ? 'idle'
                      : error
                        ? 'error'
                        : working
                          ? 'running'
                          : indicatorDone
                            ? 'done'
                            : 'idle'}
                    <!-- Inline progressive trace: live plan checklist (last turn) +
                         thinking line + per-tool lines, then the streamed answer. -->
                    {#if msg.tools.length > 0 || traceStatus !== 'idle'}
                      <TurnTrace
                        tools={enrichTools(msg.tools, $activityLogProp)}
                        status={traceStatus}
                        label={traceStatus === 'idle' ? null : indicatorLabel}
                        {isLast}
                        planSteps={isLast ? planSteps : []}
                        {taskStatus}
                      />
                    {/if}
                    {#if msg.thinking}
                      <ThinkingBlock thinking={msg.thinking} />
                    {/if}
                    {#if msg.text}
                      <div
                        class="markdown-body 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)}
                        {#if isLast && streaming}
                          <span class="stream-cursor" aria-hidden="true"></span>
                        {/if}
                      </div>
                      {#if msg.created_at}
                        <div class="msg-time">{formatTime(msg.created_at)}</div>
                      {/if}
                    {/if}
                  {/if}
                </div>
                <button
                  class="msg-action"
                  title="Copy message"
                  aria-label="Copy message"
                  onclick={() => copyMessage(msg)}
                >
                  {#if copiedId === msg.id}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
                </button>
              </div>
            {/each}
            {#if question}
              <div class="py-3"><OperatorQuestion {sessionId} {question} /></div>
            {/if}
          </div>
        </div>
        {#if scrolledUp && messages.length > 0}
          <button class="jump-bottom" onclick={jumpToBottom} aria-label="Jump to latest">
            <ArrowDownToLineIcon class="size-4" />
          </button>
        {/if}
      </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}

      {#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). Lives in the message pane (alongside the
             connection/error banners) so it consumes transcript space, NOT the
             input pane's fixed height — otherwise appearing/disappearing would
             clip the textarea and force a resize. Terminal status strip:
             spinner + fg label + primary-tinted hairline border, aligned to the
             textarea column. -->
        <div class="mx-auto w-full max-w-3xl px-4">
          {#if isStuck}
            <div class="composer-status composer-status-stuck mb-2">
              <span class="composer-status-label stuck-label">Stuck</span>
              <span class="composer-status-text"
                >no activity for {formatElapsed(elapsedSeconds)}</span
              >
              <Button
                size="xs"
                variant="outline"
                class="h-6 text-[11px]"
                onclick={handleResume}
                disabled={resuming}
              >
                {resuming ? 'Resuming…' : 'Resume'}
              </Button>
            </div>
          {:else}
            <div class="composer-status mb-2">
              <Spinner class="size-3 shrink-0 text-primary" />
              <span class="composer-status-label">Working</span>
              <span class="composer-status-text">{formatElapsed(elapsedSeconds)}</span>
            </div>
          {/if}
        </div>
      {/if}
    </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-background 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 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"
              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"
              disabled={!input.trim()}
              aria-label="Send"
            >
              <CornerDownLeftIcon class="size-3.5" />
            </Button>
          {/if}
        </form>
      </div>
    </Pane>
  </Splitpanes>
</div>

<style>
  /* ── Cyberspace / terminal chat styling ──
     Messages are full-width terminal log rows (left role-tag column +
     content), separated by hairline divide-y. Border-driven, square, no soft
     shadows — same language as the rest of the app. Prose deltas below sit on
     top of the shared .markdown-body base (app.css); a two-class selector
     (`.markdown-body.prose-chat`) wins on specificity over app.css's single
     `.markdown-body` rules deterministically, regardless of <style> injection
     order. */

  /* Message rows */
  .msg-row {
    /* role column + body; the copy action is absolutely positioned top-right */
  }
  .msg-role {
    flex-shrink: 0;
    width: 3.25rem;
    font-family: var(--font-mono);
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--muted-foreground);
    padding-top: 0.15rem;
  }
  .msg-body {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    overflow-wrap: break-word;
  }
  .user-text {
    color: var(--foreground);
  }
  .msg-time {
    font-family: var(--font-mono);
    font-size: 9px;
    color: var(--muted-foreground);
    opacity: 0.7;
  }
  .queued-hint {
    font-size: 10px;
    color: var(--muted-foreground);
  }
  .msg-action {
    position: absolute;
    top: 0.6rem;
    right: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    border: 1px solid transparent;
    background: transparent;
    color: var(--muted-foreground);
    opacity: 0;
    transition:
      opacity 0.12s,
      color 0.12s,
      border-color 0.12s;
  }
  .msg-row:hover .msg-action,
  .msg-action:focus-visible {
    opacity: 1;
  }
  .msg-action:hover {
    color: var(--foreground);
    border-color: var(--border);
  }

  /* Glyph backdrop — fills the message pane, centers the glyph, stays put
     while the transcript scrolls over it. pointer-events none so it never
     intercepts scroll/click. */
  .glyph-backdrop {
    position: absolute;
    inset: 0;
    z-index: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
    overflow: hidden;
  }

  /* Jump-to-latest button — border-driven square, sits over the transcript */
  .jump-bottom {
    position: absolute;
    right: 1rem;
    bottom: 1rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    border: 1px solid var(--border);
    background: var(--background);
    color: var(--muted-foreground);
    box-shadow: 2px 2px 0 0 var(--border);
  }
  .jump-bottom:hover {
    color: var(--foreground);
    border-color: var(--foreground);
  }

  /* Prose deltas (markdown-body base lives in app.css). */
  .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;
  }

  /* Headings — short accent rule under each; first heading in a message
     doesn't get extra top margin. */
  .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;
    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
    );
  }

  .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 hairline 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;
  }

  /* Composer "working/queued" status strip — terminal status-bar idiom: a
     hairline primary-tinted border, square corners, a spinner + an uppercase
     fg label + muted detail. Border-driven, no shadow. */
  .composer-status {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.3rem 0.6rem;
    border: 1px solid color-mix(in oklab, var(--primary) 35%, var(--border));
    border-radius: 0;
    background: color-mix(in oklab, var(--primary) 6%, var(--card));
    font-size: 11px;
    color: var(--muted-foreground);
  }
  .composer-status-label {
    color: var(--foreground);
    text-transform: uppercase;
    letter-spacing: 0.08em;
  }
  .composer-status-text {
    color: var(--muted-foreground);
  }
  .composer-status-stuck {
    border-color: color-mix(in oklab, var(--warning) 50%, var(--border));
    background: color-mix(in oklab, var(--warning) 8%, var(--card));
  }
  .stuck-label {
    color: var(--warning);
  }

  /* Code copy button — global: injected via render() into {html} blocks.
     Square (cyberspace), not rounded. */
  .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;
    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;
    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;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .stream-cursor,
    .msg-action {
      animation: none;
      transition: none;
    }
  }
</style>
