// Kayser v2 — Chat-first home: messages, inline cards, approval inline, auto-handled receipts.

// Markdown renderer for Kayser bubbles. Returns __html for
// dangerouslySetInnerHTML. Sanitises with DOMPurify so an LLM-emitted
// <script> never executes. Falls back to escaped text + <br> if the
// libs failed to load (e.g. a CDN/Caddy hiccup) — the chat never breaks.
function kxRenderMarkdown(text) {
  if (typeof text !== 'string' || !text) return { __html: '' };
  if (!window.marked || !window.DOMPurify) {
    const esc = text
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
      .replace(/\n/g, '<br>');
    return { __html: esc };
  }
  try {
    window.marked.setOptions({ gfm: true, breaks: true });
  } catch (_) {}
  const raw = window.marked.parse(text);
  const clean = window.DOMPurify.sanitize(raw, {
    FORBID_TAGS: ['style', 'script', 'iframe', 'form', 'input', 'object', 'embed'],
    FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'style'],
  });
  return { __html: clean };
}

// Highlight any <pre><code> inside a freshly-rendered bubble. Runs
// after React commits — see KxV2KayserText below. Idempotent per node.
function kxDecorateCode(rootEl) {
  if (!rootEl || !window.hljs) return;
  rootEl.querySelectorAll('pre code').forEach((codeEl) => {
    if (codeEl.dataset.kxHl === '1') return;
    try {
      window.hljs.highlightElement(codeEl);
      codeEl.dataset.kxHl = '1';
    } catch (_) {}
  });
}

// Humanise the raw MCP tool identifier Hermes emits — turns
// `mcp_brave_search_brave_web_search` into "Searching the web".
function kxFriendlyToolName(s) {
  // ── Hermes-native tool names (post-A7, from /api/sessions/{id}/chat/stream) ──
  // These are the actual `tool_name` values Hermes emits on tool.started events.
  // Keep this list above the MCP / generic fallback because some Hermes tools
  // share substrings with MCP tools (`web_search` etc.).
  if (s === 'session_search')                  return 'Looking through past conversations';
  if (s === 'web_search')                       return 'Searching the web';
  if (s === 'web_extract')                      return 'Reading a webpage';
  if (s === 'vision_analyze')                   return 'Looking at the image';
  if (s === 'memory')                           return 'Saving to memory';
  if (s === 'read_file' || s === 'file_read')   return 'Reading a file';
  if (s === 'write_file' || s === 'file_write') return 'Writing a file';
  if (s === 'list_files' || s === 'file_list')  return 'Browsing files';
  if (s === 'search_files')                     return 'Searching files';
  if (s === 'execute_code' || s === 'python')   return 'Running code';
  if (s === 'cronjob')                          return 'Managing scheduled tasks';
  if (s === 'todo')                             return 'Updating the task list';
  if (s === 'clarify')                          return 'Asking for clarification';
  if (s === 'skill_view')                       return 'Loading a skill';
  if (s === 'skill_manage' || s === 'skills_list') return 'Reviewing skills';
  if (s === 'kanban_create' || s === 'kanban_show'
      || s === 'kanban_complete' || s === 'kanban_block') return 'Updating the task board';
  if (s.startsWith('browser_'))                 return 'Browsing the web';
  if (s.startsWith('terminal'))                 return 'Running a shell command';
  // Internal reasoning preview — surface as the agent thinking even though
  // we drop _thinking deltas at the atlas-api layer. Belt-and-suspenders.
  if (s === '_thinking')                        return 'Thinking';

  // ── MCP servers ──
  if (/brave/.test(s) && /local/.test(s))       return 'Searching nearby';
  if (/brave/.test(s) && /web/.test(s))         return 'Searching the web';
  if (/brave/.test(s))                          return 'Searching the web';
  if (/gmail|email|inbox/.test(s))              return 'Reading your inbox';
  if (/calendar/.test(s))                       return 'Checking your calendar';
  if (/drive|sheet/.test(s))                    return 'Updating your files';
  if (/sms|twilio/.test(s))                     return 'Sending a text';
  if (/stripe|invoice|payment/.test(s))         return 'Checking payments';

  // ── Generic fallback ──
  // strip mcp_ prefix, take the last segment, de-snake_case it, sentence-case
  // the first letter. e.g. ``mcp_brave_search_brave_web_search`` → "Search"
  const stripped = s.replace(/^mcp[_-]/, '');
  const segments = stripped.split(/[_-]/).filter(Boolean);
  const last = segments[segments.length - 1] || stripped;
  return last.charAt(0).toUpperCase() + last.slice(1).replace(/[_-]/g, ' ');
}

function kxHumanizeTool(raw) {
  if (!raw) return 'Running a tool';
  // Live-pill envelopes from app.jsx may pack a preview onto the tool
  // name as "tool_name preview text" (separated by the first space).
  // Split it apart so the name maps cleanly through the lookup table
  // and the preview rides as a subtitle.
  const rawStr = String(raw);
  const space = rawStr.indexOf(' ');
  const namePart = space === -1 ? rawStr : rawStr.slice(0, space);
  const previewPart = space === -1 ? '' : rawStr.slice(space + 1).trim();
  const friendly = kxFriendlyToolName(namePart.toLowerCase());
  if (previewPart) {
    // Truncate so the pill stays one line. ASCII middle-dot reads
    // cleaner than ":" between a sentence-case label and free text.
    const trimmed = previewPart.length > 50
      ? previewPart.slice(0, 47) + '…'
      : previewPart;
    return `${friendly} · ${trimmed}`;
  }
  return friendly;
}

// ----- Mock conversation seed -----
// Mixes: ambient text, proactive push w/ rich card, approval card, auto-handled receipt, user reply.
const KXV2_SEED = [
  { id: 1, kind: 'kayser-text', t: '8:42 AM',
    text: "Morning. Rain tomorrow afternoon — moved Patel's exterior to Thursday. Two things below need you.",
    replies: ["Sounds good", "Why Thursday?"] },

  { id: 2, kind: 'kayser-card', t: '8:42 AM',
    card: {
      type: 'auto-handled',
      title: 'Rescheduled Patel · Exterior repaint',
      subtitle: 'Wed 2pm → Thu 9am · 70% rain forecast',
      meta: 'Done on my own — based on what you taught me about weather days.',
    } },

  { id: 3, kind: 'kayser-card', t: '8:43 AM',
    card: {
      type: 'approval',
      title: 'Send reminder to Garcia',
      subtitle: 'Invoice #1031 · $4,800 · 7 days overdue',
      body: '"Hi Maria — just a friendly nudge on invoice #1031 ($4,800). Let me know if you\'d like to set up a payment plan."',
      actions: ['Send', 'Edit', 'Skip'],
    } },

  { id: 4, kind: 'kayser-card', t: '8:43 AM',
    card: {
      type: 'insight',
      title: 'Cashflow heads-up',
      subtitle: '3 invoices · $11,200 outstanding · avg 9 days late',
      cta: 'Draft a follow-up plan',
    } },

  { id: 5, kind: 'user-text', t: '8:45 AM',
    text: "Send the Garcia reminder. Show me today's schedule." },

  { id: 6, kind: 'kayser-card', t: '8:45 AM',
    card: {
      type: 'auto-handled',
      title: 'Sent reminder · Garcia · #1031',
      subtitle: 'Delivered via SMS · read 8:46 AM',
      meta: 'You said send. I sent it. You can undo for 5 more minutes.',
    } },

  { id: 7, kind: 'kayser-card', t: '8:45 AM',
    card: {
      type: 'schedule',
      title: 'Today · Tue Apr 28',
      rows: [
        { time: '9:30 AM', who: 'Chen kitchen — finish trim', loc: '1840 Birch St' },
        { time: '1:00 PM', who: 'Estimate — Williams porch', loc: '442 Cedar Ln' },
        { time: '3:30 PM', who: 'Pickup — Sherwin Williams', loc: 'Order #882' },
      ],
    } },
];

const KXV2_ONBOARDING_SEED = [
  { id: 'ob-1', kind: 'kayser-text', t: 'now',
    text: "Hi — I'm Kayser. Think of me as a second set of hands for your business.",
    replies: ["Nice to meet you"] },
  { id: 'ob-2', kind: 'kayser-text', t: 'now',
    text: "I'll watch your email, texts, and calendar. When something needs your call, I'll ask. Otherwise I'll handle it and tell you after." },
  { id: 'ob-3', kind: 'kayser-text', t: 'now',
    text: "I start out asking about everything. The more you approve, the more I'll do on my own. You can teach me anytime — just say what you want me to do.",
    replies: ["Got it", "Show me what you can do", "Where do I set things up?"] },
];

// ----- Bubble + cards -----

function KxV2UserBubble({ text, t, attachments }) {
  return (
    <div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px 14px' }}>
      <div style={{ maxWidth: '78%' }}>
        {attachments && attachments.length > 0 && (
          <KxV2AttachmentChips attachments={attachments} align="end" />
        )}
        <div style={{
          background: KX2.brand, color: '#fff',
          padding: '9px 13px', borderRadius: '16px 16px 4px 16px',
          fontSize: 14.5, lineHeight: 1.4, fontFamily: KX2.fontUI,
          boxShadow: '0 6px 18px rgba(124,58,237,.35)',
        }}>{text}</div>
        <div style={{ fontSize: 10, color: KX2.ink3, marginTop: 4, textAlign: 'right',
          fontFamily: KX2.fontMono }}>{t}</div>
      </div>
    </div>
  );
}

// ============ ATTACHMENT RENDERING ============
//
// One attachment becomes one of four shapes, picked by content_type:
//
//   image/*   →  rounded inline thumbnail (the server pre-renders a
//                JPEG preview for HEIC etc. so this just works)
//   audio/*   →  inline <audio> player
//   video/*   →  inline <video> player with controls
//   other     →  Apple-Messages-style file card (icon + name + size,
//                whole thing clickable to download)
//
// All four route through ``KxV2AttachmentItem`` and render in a flex
// row above the bubble. Image / audio / video previews mean the
// rendered chip is taller; the file card stays compact. Click-to-
// download works on every variant.

function kxFmtBytes(n) {
  if (!n) return '';
  if (n < 1024) return `${n} B`;
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
  return `${(n / 1024 / 1024).toFixed(1)} MB`;
}

function kxAttachmentUrls(att) {
  if (!att || !att.id || !window.KX_API_BASE || !window.KX_TOKEN) {
    return { original: null, preview: null };
  }
  const tok = encodeURIComponent(window.KX_TOKEN);
  const id = encodeURIComponent(att.id);
  return {
    original: `${window.KX_API_BASE}/uploads/${id}?token=${tok}`,
    preview:  `${window.KX_API_BASE}/uploads/${id}/preview?token=${tok}`,
  };
}

// File-type categorisation — single source of truth used by chips on
// the bubble AND chips in the composer queue.
function kxAttachmentKind(contentType) {
  const ct = (contentType || '').toLowerCase();
  if (ct.startsWith('image/')) return 'image';
  if (ct.startsWith('audio/')) return 'audio';
  if (ct.startsWith('video/')) return 'video';
  if (ct === 'application/pdf') return 'pdf';
  return 'file';
}

// Glyph + colour used in non-thumbnail file cards. Keep this tight —
// adding emojis here makes the UI noisy on dense conversation views.
function kxAttachmentBadge(contentType) {
  const k = kxAttachmentKind(contentType);
  if (k === 'image') return { label: 'IMG', bg: 'rgba(96,165,250,.22)', fg: '#bfdbfe' };
  if (k === 'audio') return { label: 'AUD', bg: 'rgba(52,211,153,.22)', fg: '#a7f3d0' };
  if (k === 'video') return { label: 'VID', bg: 'rgba(244,114,182,.22)', fg: '#fbcfe8' };
  if (k === 'pdf')   return { label: 'PDF', bg: 'rgba(251,113,133,.22)', fg: '#fda4af' };
  // Pull the extension if we can — "DOCX" / "ZIP" reads as more useful
  // than the generic "FILE" label.
  return { label: 'FILE', bg: 'rgba(255,255,255,.10)', fg: KX2.ink2 };
}

function KxV2AttachmentItem({ att }) {
  const kind = kxAttachmentKind(att.content_type);
  const urls = kxAttachmentUrls(att);

  // Image — inline thumbnail. The server pre-rendered a JPEG even for
  // HEIC, so `<img>` works on every browser. onError falls back to the
  // file-card shape (e.g. preview generation failed for some odd
  // image variant).
  if (kind === 'image' && urls.preview && att.has_preview !== false) {
    return (
      <a href={urls.original || urls.preview} target="_blank" rel="noopener noreferrer"
        title={att.filename}
        style={{
          ...kx2GlassThin({ borderRadius: 14 }),
          padding: 4, display: 'block', maxWidth: 280,
          textDecoration: 'none',
        }}>
        <img src={urls.preview} alt={att.filename}
          onError={(e) => {
            // Hide the broken image and let the parent gracefully fall
            // back — easier than a state hook here.
            e.currentTarget.style.display = 'none';
            if (e.currentTarget.nextSibling) {
              e.currentTarget.nextSibling.style.display = 'flex';
            }
          }}
          style={{
            display: 'block', maxWidth: '100%', maxHeight: 260,
            borderRadius: 10, objectFit: 'cover',
          }}
        />
        {/* Hidden-by-default fallback shown when img fails to load. */}
        <div style={{
          display: 'none', alignItems: 'center', gap: 8,
          padding: '6px 8px',
        }}>
          <KxV2AttachmentBadge contentType={att.content_type} />
          <span style={{
            fontFamily: KX2.fontUI, fontSize: 12, color: KX2.ink,
            whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
            flex: 1, minWidth: 0,
          }}>{att.filename}</span>
        </div>
      </a>
    );
  }

  // Audio — inline player.
  if (kind === 'audio' && urls.original) {
    return (
      <div style={{
        ...kx2GlassThin({ borderRadius: 14 }),
        padding: 8, maxWidth: 320, minWidth: 240,
      }}>
        <audio controls src={urls.original} style={{ width: '100%', display: 'block' }} />
        <div style={{
          marginTop: 4, fontFamily: KX2.fontUI, fontSize: 11,
          color: KX2.ink3, lineHeight: 1.3,
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
        }}>{att.filename} · {kxFmtBytes(att.size_bytes)}</div>
      </div>
    );
  }

  // Video — inline player.
  if (kind === 'video' && urls.original) {
    return (
      <div style={{
        ...kx2GlassThin({ borderRadius: 14 }),
        padding: 4, maxWidth: 360,
      }}>
        <video controls src={urls.original}
          style={{
            display: 'block', maxWidth: '100%', maxHeight: 240,
            borderRadius: 10, background: '#000',
          }} />
        <div style={{
          padding: '4px 6px',
          fontFamily: KX2.fontUI, fontSize: 11,
          color: KX2.ink3, lineHeight: 1.3,
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
        }}>{att.filename} · {kxFmtBytes(att.size_bytes)}</div>
      </div>
    );
  }

  // Everything else — a proper file card. Whole card is the link.
  const inner = (
    <div style={{
      ...kx2GlassThin({ borderRadius: 12 }),
      display: 'flex', alignItems: 'center', gap: 10,
      padding: '8px 12px', minWidth: 220, maxWidth: 320,
      textDecoration: 'none', color: KX2.ink,
    }}>
      <KxV2AttachmentBadge contentType={att.content_type} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div title={att.filename} style={{
          fontFamily: KX2.fontUI, fontSize: 13, color: KX2.ink, fontWeight: 500,
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
        }}>{att.filename}</div>
        <div style={{
          fontFamily: KX2.fontMono, fontSize: 10, color: KX2.ink3, marginTop: 2,
        }}>{kxFmtBytes(att.size_bytes)}</div>
      </div>
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
        stroke="currentColor" strokeWidth="2" strokeLinecap="round"
        strokeLinejoin="round" style={{ color: KX2.ink3, flexShrink: 0 }}>
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
        <polyline points="7 10 12 15 17 10"/>
        <line x1="12" y1="15" x2="12" y2="3"/>
      </svg>
    </div>
  );
  return urls.original
    ? <a href={urls.original} target="_blank" rel="noopener noreferrer"
        style={{ textDecoration: 'none' }}>{inner}</a>
    : inner;
}

function KxV2AttachmentBadge({ contentType }) {
  const b = kxAttachmentBadge(contentType);
  return (
    <div style={{
      width: 36, height: 44, borderRadius: 6,
      background: b.bg, color: b.fg,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontFamily: KX2.fontMono, fontSize: 10, fontWeight: 700,
      letterSpacing: 0.3, flexShrink: 0,
    }}>{b.label}</div>
  );
}

function KxV2AttachmentChips({ attachments, align = 'start' }) {
  return (
    <div style={{
      display: 'flex', flexDirection: 'column', gap: 6,
      alignItems: align === 'end' ? 'flex-end' : 'flex-start',
      marginBottom: 6,
    }}>
      {attachments.map((a) => (
        <KxV2AttachmentItem key={a.id || a.filename} att={a} />
      ))}
    </div>
  );
}

function KxV2KayserText({ text, t, replies, onReply, streaming, tool_progress, tool_phase, attachments, tool_calls }) {
  // After every text update, re-run hljs on any code blocks. Cheap
  // for short messages; idempotent so repeated chunks don't re-process.
  const bubbleRef = React.useRef(null);
  React.useEffect(() => {
    if (!streaming) kxDecorateCode(bubbleRef.current);
  }, [text, streaming]);

  const pillClass = tool_phase === 'thinking'
    ? 'kx-tool-pill kx-tool-pill--thinking'
    : tool_phase === 'failed'
      ? 'kx-tool-pill kx-tool-pill--failed'
      : 'kx-tool-pill';

  return (
    <div style={{ display: 'flex', padding: '4px 14px' }}>
      <div style={{ maxWidth: '88%' }}>
        {tool_progress && (
          <div className={pillClass} style={{ marginBottom: 6 }}>
            <span className="kx-tool-pill-dot" />
            <span>{kxHumanizeTool(tool_progress)}</span>
          </div>
        )}
        {attachments && attachments.length > 0 && (
          <KxV2AttachmentChips attachments={attachments} align="start" />
        )}
        <div style={{
          ...kx2GlassThin({ borderRadius: '16px 16px 16px 4px' }),
          padding: '9px 13px', color: KX2.ink, fontSize: 14.5, lineHeight: 1.45,
          fontFamily: KX2.fontUI,
        }}>
          <span
            ref={bubbleRef}
            className="kx-md"
            dangerouslySetInnerHTML={kxRenderMarkdown(text)}
          />
          {streaming && <span className="kx-stream-cursor" />}
        </div>
        {/* Persistent tools-used affordance. Stays attached to the
            message so the operator can audit "what did the agent
            actually do to get this answer?" — collapsed by default,
            click to expand into a stack of tool cards. */}
        {!streaming && tool_calls && tool_calls.length > 0 && (
          <KxV2ToolCallsDisclosure tools={tool_calls} />
        )}
        {replies && replies.length > 0 && !streaming && (
          <div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
            {replies.map(r => (
              <button key={r} onClick={() => onReply?.(r)} style={{
                padding: '6px 12px', borderRadius: 14, fontSize: 12.5,
                fontFamily: KX2.fontUI, cursor: 'pointer',
                background: KX2.chipBg, color: KX2.ink,
                border: `1px solid ${KX2.glassLine}`,
              }}>{r}</button>
            ))}
          </div>
        )}
        <div style={{ fontSize: 10, color: KX2.ink3, marginTop: 4,
          fontFamily: KX2.fontMono }}>{t}</div>
      </div>
    </div>
  );
}

// ============ TOOL-CALLS DISCLOSURE ============
// Renders below an assistant message — a small "▸ N tools used" row
// that expands into a stack of tool cards. Each card surfaces what
// Hermes tells us about the call: humanised name + preview text +
// formatted args + status + duration. Kept compact so it doesn't
// dominate the conversation flow; the operator opts in by clicking.
function KxV2ToolCallsDisclosure({ tools }) {
  const [open, setOpen] = React.useState(false);
  const succeeded = tools.filter(t => t.status === 'completed').length;
  const failed = tools.filter(t => t.status === 'failed').length;
  const countLabel = tools.length === 1 ? '1 tool' : `${tools.length} tools`;
  const fmtMs = (ms) => {
    if (ms == null) return '';
    if (ms < 1000) return `${ms} ms`;
    return `${(ms / 1000).toFixed(1)} s`;
  };
  const fmtArgs = (a) => {
    if (a == null) return '';
    if (typeof a === 'string') return a;
    try { return JSON.stringify(a, null, 2); } catch (_) { return String(a); }
  };
  return (
    <div style={{ marginTop: 6 }}>
      <button onClick={() => setOpen(o => !o)} style={{
        background: 'transparent', border: 'none', padding: '2px 0',
        cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6,
        fontFamily: KX2.fontMono, fontSize: 10, color: KX2.ink3,
        letterSpacing: 0.4,
      }}>
        <span style={{
          display: 'inline-block',
          transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
          transition: 'transform .12s ease',
        }}>▸</span>
        <span>
          {countLabel} used
          {failed > 0 ? ` · ${failed} failed` : ''}
        </span>
      </button>
      {open && (
        <div style={{
          marginTop: 6, display: 'flex', flexDirection: 'column', gap: 6,
        }}>
          {tools.map((tc, i) => {
            const isFailed = tc.status === 'failed';
            const label = kxHumanizeTool(tc.name);
            return (
              <div key={i} style={{
                ...kx2GlassThin({ borderRadius: 10 }),
                padding: '8px 10px', fontFamily: KX2.fontUI, fontSize: 12,
                borderLeft: `2px solid ${isFailed ? KX2.reject : 'rgba(124,58,237,.5)'}`,
              }}>
                <div style={{
                  display: 'flex', alignItems: 'baseline', gap: 8,
                  marginBottom: 4,
                }}>
                  <span style={{ fontWeight: 600, color: KX2.ink }}>{label}</span>
                  <span style={{ fontFamily: KX2.fontMono, fontSize: 10, color: KX2.ink3 }}>
                    {tc.name}
                  </span>
                  <span style={{ flex: 1 }} />
                  <span style={{
                    fontFamily: KX2.fontMono, fontSize: 10,
                    color: isFailed ? KX2.reject : KX2.ink3,
                  }}>
                    {tc.status}{tc.duration_ms != null ? ` · ${fmtMs(tc.duration_ms)}` : ''}
                  </span>
                </div>
                {tc.preview && (
                  <div style={{
                    fontFamily: KX2.fontUI, fontSize: 12, color: KX2.ink2,
                    marginBottom: 4,
                  }}>{tc.preview}</div>
                )}
                {tc.args && (
                  <div style={{ marginBottom: 4 }}>
                    <div style={{
                      fontFamily: KX2.fontMono, fontSize: 9, color: KX2.ink3,
                      letterSpacing: 0.4, marginBottom: 2,
                    }}>ARGS</div>
                    <pre style={{
                      margin: 0, padding: '6px 8px', borderRadius: 6,
                      background: 'rgba(0,0,0,.18)',
                      fontFamily: KX2.fontMono, fontSize: 11, color: KX2.ink2,
                      whiteSpace: 'pre-wrap', wordBreak: 'break-word',
                      maxHeight: 140, overflow: 'auto',
                    }}>{fmtArgs(tc.args)}</pre>
                  </div>
                )}
                {tc.result_preview && (
                  <div>
                    <div style={{
                      fontFamily: KX2.fontMono, fontSize: 9, color: KX2.ink3,
                      letterSpacing: 0.4, marginBottom: 2,
                    }}>RESULT</div>
                    <pre style={{
                      margin: 0, padding: '6px 8px', borderRadius: 6,
                      background: 'rgba(0,0,0,.22)',
                      fontFamily: KX2.fontMono, fontSize: 11,
                      color: isFailed ? '#fda4af' : KX2.ink2,
                      whiteSpace: 'pre-wrap', wordBreak: 'break-word',
                      maxHeight: 220, overflow: 'auto',
                    }}>{tc.result_preview}</pre>
                  </div>
                )}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

// Kayser-flavoured typing indicator — three pulsing dots inside a
// glass bubble. Used while we wait for the first chunk to arrive.
// Visual language matches KxV2KayserText so the user perceives it
// as "Kayser is composing" rather than a generic spinner.
function KxV2KayserTyping({ tool_progress, tool_phase }) {
  const pillClass = tool_phase === 'thinking'
    ? 'kx-tool-pill kx-tool-pill--thinking'
    : tool_phase === 'failed'
      ? 'kx-tool-pill kx-tool-pill--failed'
      : 'kx-tool-pill';
  return (
    <div style={{ display: 'flex', padding: '4px 14px' }}>
      <div style={{ maxWidth: '88%' }}>
        {tool_progress && (
          <div className={pillClass} style={{ marginBottom: 6 }}>
            <span className="kx-tool-pill-dot" />
            <span>{kxHumanizeTool(tool_progress)}</span>
          </div>
        )}
        <div style={{
          ...kx2GlassThin({ borderRadius: '16px 16px 16px 4px' }),
          padding: '11px 14px', color: KX2.ink, lineHeight: 1,
          display: 'inline-block',
        }}>
          <span className="kx-dot" />
          <span className="kx-dot" />
          <span className="kx-dot" />
        </div>
      </div>
    </div>
  );
}

function KxV2Card({ card, t, onTap, onAction }) {
  const accentByType = {
    'auto-handled': KX2.approve,
    'approval':     KX2.brand,
    'insight':      KX2.warn,
    'schedule':     KX2.edit,
  };
  const labelByType = {
    'auto-handled': '✓ Auto-handled',
    'approval':     '◇ Needs you',
    'insight':      '◌ Watching',
    'schedule':     '◷ Schedule',
  };
  const accent = accentByType[card.type];

  const tappable = card.type === 'approval' || card.type === 'auto-handled';
  return (
    <div style={{ padding: '4px 14px' }}>
      <div onClick={tappable ? () => onTap?.(card) : undefined} style={{
        ...kx2Glass({}),
        padding: 12,
        borderLeft: card.type === 'approval' ? `2px solid ${accent}` : `1px solid ${KX2.glassLine}`,
        cursor: tappable ? 'pointer' : 'default',
        transition: 'transform 120ms, background 120ms',
      }}
      onMouseDown={tappable ? (e) => e.currentTarget.style.transform = 'scale(.99)' : undefined}
      onMouseUp={tappable ? (e) => e.currentTarget.style.transform = 'scale(1)' : undefined}
      onMouseLeave={tappable ? (e) => e.currentTarget.style.transform = 'scale(1)' : undefined}>
        {/* Header row */}
        <div style={{
          display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
        }}>
          <span style={{
            fontFamily: KX2.fontMono, fontSize: 10, fontWeight: 600,
            letterSpacing: 0.4, color: accent, textTransform: 'uppercase',
          }}>{labelByType[card.type]}</span>
          <span style={{ flex: 1 }} />
          <span style={{ fontFamily: KX2.fontMono, fontSize: 10, color: KX2.ink3 }}>{t}</span>
        </div>

        <div style={{
          fontFamily: KX2.fontBrand, fontSize: 15, fontWeight: 600,
          color: KX2.ink, lineHeight: 1.3, letterSpacing: -0.2,
        }}>{card.title}</div>

        {card.subtitle && (
          <div style={{
            fontSize: 12.5, color: KX2.ink2, marginTop: 4, lineHeight: 1.4,
            fontFamily: KX2.fontUI,
          }}>{card.subtitle}</div>
        )}

        {card.body && (
          <div style={{
            ...kx2GlassThin({ borderRadius: 10 }),
            padding: '8px 10px', marginTop: 10,
            fontSize: 13, color: KX2.ink2, fontStyle: 'italic',
            lineHeight: 1.45, fontFamily: KX2.fontUI,
          }}>{card.body}</div>
        )}

        {card.rows && (
          <div style={{ marginTop: 10, display: 'grid', gap: 6 }}>
            {card.rows.map((r, i) => (
              <div key={i} style={{
                display: 'grid', gridTemplateColumns: '64px 1fr', gap: 10,
                paddingTop: i === 0 ? 0 : 6,
                borderTop: i === 0 ? 'none' : `1px solid ${KX2.glassLine}`,
              }}>
                <div style={{
                  fontFamily: KX2.fontMono, fontSize: 11, color: KX2.ink2,
                  fontVariantNumeric: 'tabular-nums', paddingTop: 1,
                }}>{r.time}</div>
                <div>
                  <div style={{ fontSize: 13, color: KX2.ink, lineHeight: 1.3 }}>{r.who}</div>
                  <div style={{ fontSize: 11, color: KX2.ink3, marginTop: 1 }}>{r.loc}</div>
                </div>
              </div>
            ))}
          </div>
        )}

        {card.actions && (
          <div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
            {card.actions.map((a, i) => (
              <button key={a} onClick={(e) => { e.stopPropagation(); onAction?.(card, a); }} style={{
                flex: i === 0 ? 1.5 : 1,
                padding: '8px 10px', borderRadius: 10, fontSize: 12.5, fontWeight: 600,
                fontFamily: KX2.fontUI, cursor: 'pointer',
                background: i === 0 ? KX2.brand : KX2.chipBg,
                color: i === 0 ? '#fff' : KX2.ink,
                border: i === 0 ? 'none' : `1px solid ${KX2.glassLine}`,
              }}>{a}</button>
            ))}
          </div>
        )}

        {tappable && (
          <div style={{
            fontSize: 10, color: KX2.ink3, marginTop: 8, textAlign: 'right',
            fontFamily: KX2.fontMono, letterSpacing: 0.3,
          }}>{card.type === 'approval' ? 'tap for details →' : 'tap to correct →'}</div>
        )}

        {card.cta && (
          <button onClick={(e) => { e.stopPropagation(); onAction?.(card, card.cta); }} style={{
            marginTop: 12, width: '100%', padding: '8px 10px',
            borderRadius: 10, fontSize: 12.5, fontWeight: 600,
            fontFamily: KX2.fontUI, cursor: 'pointer',
            background: KX2.chipBg, color: KX2.ink,
            border: `1px solid ${KX2.glassLine}`,
          }}>{card.cta} →</button>
        )}

        {card.meta && (
          <div style={{
            fontSize: 10.5, color: KX2.ink3, marginTop: 10,
            fontFamily: KX2.fontMono, letterSpacing: 0.2,
          }}>{card.meta}</div>
        )}
      </div>
    </div>
  );
}

// ----- Main thread -----

function KxV2Thread({ messages, onCardTap, onCardAction, onReply, header }) {
  const ref = React.useRef(null);
  const userInitiatedRef = React.useRef(false);
  React.useEffect(() => {
    // Only auto-scroll if user is already near the bottom (or no header pinning the top)
    if (!ref.current) return;
    const el = ref.current;
    const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
    if (nearBottom || !header || userInitiatedRef.current) {
      el.scrollTop = el.scrollHeight;
    }
    userInitiatedRef.current = true;
  }, [messages, header]);
  return (
    <div ref={ref} style={{
      flex: 1, overflowY: 'auto', paddingTop: 8, paddingBottom: 8,
    }}>
      {header}
      {messages.map((m) => {
        if (m.kind === 'user-text')      return <KxV2UserBubble key={m.id} text={m.text} t={m.t} attachments={m.attachments} />;
        if (m.kind === 'kayser-typing')  return <KxV2KayserTyping key={m.id} tool_progress={m.tool_progress} tool_phase={m.tool_phase} />;
        if (m.kind === 'kayser-text')    return <KxV2KayserText key={m.id} text={m.text} t={m.t}
                                                  replies={m.replies} onReply={onReply}
                                                  streaming={m.streaming}
                                                  tool_progress={m.tool_progress} tool_phase={m.tool_phase}
                                                  attachments={m.attachments}
                                                  tool_calls={m.tool_calls} />;
        if (m.kind === 'kayser-card')    return <KxV2Card key={m.id} card={m.card} t={m.t} onTap={onCardTap} onAction={onCardAction} />;
        return null;
      })}
    </div>
  );
}

Object.assign(window, {
  KxV2Thread, KxV2Card, KxV2KayserTyping, KXV2_SEED, KXV2_ONBOARDING_SEED,
  // Attachment helpers — used by both the chat bubbles here AND the
  // composer queue rows in sheets.jsx (which loads after this file).
  KxV2AttachmentChips, KxV2AttachmentItem, KxV2AttachmentBadge,
  kxAttachmentKind, kxAttachmentBadge, kxAttachmentUrls, kxFmtBytes,
});
