// Personal Conversations — PERSONAL-mode view of agent conversations that the
// Team Sync daemon captured from your coding agents (Claude Code, Codex, Cursor)
// and synced into your memory.
//
// Endpoints, all scoped to the signed-in user by the bearer token:
//
//   GET /personal/synced-conversations
//     → { conversations: [{ id, agent_type, title, project, cwd, git_branch,
//                           device_id, native_session_id, has_turns,
//                           created_at, updated_at }] }
//   GET /personal/synced-conversations/{id}
//     → { conversation: {…summary…}, turns: [Turn] }
//       Turn  = { seq, role, timestamp, native_type?, blocks: [Block] }
//       Block = { type: text|thinking|tool_call|tool_result|image|file,
//                 text?, name?, tool_input?, output?, status?, path?,
//                 artifact?: { sha256, size, mime?, inline_text? } }
//   GET /personal/synced-conversations/{id}/artifacts/{sha256}
//     → raw artifact bytes (the artifact's mime). Fetched LAZILY — only when
//       the user opens an artifact that has no inline_text.
//
// The list is a master pane; selecting a conversation loads its turns into the
// detail pane, which renders each turn's blocks. When there's no live backend
// (PARCLE_API_BASE unset) a canned mock keeps the surface demoable standalone,
// mirroring the other personal surfaces. pcLive() comes from
// personal_connectors_data.jsx.

const { useState: useConvState, useEffect: useConvEffect } = React;

// agent_type → display label + accent dot for the source chip.
const CONV_AGENT_META = {
  claude:      { label: 'Claude Code', color: '#D97757' },
  codex:       { label: 'Codex',       color: '#10A37F' },
  cursor:      { label: 'Cursor',      color: '#111827' },
  folder_pick: { label: 'Local Folder', color: '#475569' },
};

function convAgentMeta(agentType) {
  return CONV_AGENT_META[agentType] || { label: agentType || 'Agent', color: '#64748B' };
}

// memory_status → chip styling. Backend derives this from the ingest cursor:
//   pending   尚未投影（排队中 / 空会话）
//   ingesting 正在写入记忆（worker 进行中）
//   ingested  已写入记忆
//   failed    失败，可手动重试
const CONV_MEMORY_META = {
  pending:   { label: 'Queued',    cls: '',        Icon: Icons.Dot },
  ingesting: { label: 'Ingesting', cls: 'accent',  Icon: Icons.Spinner, spin: true },
  ingested:  { label: 'In memory', cls: 'success', Icon: Icons.Check },
  failed:    { label: 'Failed',    cls: 'error',   Icon: Icons.AlertTri },
};

// Compact memory-projection status chip. Renders nothing for an unknown status
// so an older backend (no memory_status) degrades cleanly.
function ConvMemoryStatus({ status, compact }) {
  const m = CONV_MEMORY_META[status];
  if (!m) return null;
  const Icon = m.Icon;
  return (
    <span className={'chip' + (m.cls ? ' ' + m.cls : '')}
      style={compact ? { height: 20, padding: '2px 7px', fontSize: 11, gap: 5 } : null}>
      <Icon size={compact ? 11 : 12}
        style={m.spin ? { animation: 'parcle-spin .8s linear infinite' } : null} />
      {m.label}
    </span>
  );
}

// role → label for a transcript turn.
const CONV_ROLE_META = {
  user:      { label: 'User' },
  assistant: { label: 'Assistant' },
  system:    { label: 'System' },
  tool:      { label: 'Tool' },
};

// role → bubble styling. User turns get an accent-tinted background + accent
// label/bar so they stand apart from the neutral assistant bubbles at a glance;
// tool/system stay quiet. `bar` is an optional left accent stripe.
const CONV_ROLE_STYLE = {
  user:      { bg: 'var(--accent-muted)', border: 'var(--accent-muted)', label: 'var(--accent-text)',   bar: 'var(--accent)' },
  assistant: { bg: 'var(--surface)',      border: 'var(--border-subtle)', label: 'var(--text-secondary)', bar: null },
  tool:      { bg: 'var(--bg)',           border: 'var(--border-subtle)', label: 'var(--text-tertiary)',  bar: null },
  system:    { bg: 'var(--bg)',           border: 'var(--border-subtle)', label: 'var(--text-tertiary)',  bar: null },
};

function convRoleStyle(role) {
  return CONV_ROLE_STYLE[role] || CONV_ROLE_STYLE.system;
}

function convFmtTime(value) {
  if (!value) return '';
  const d = new Date(value);
  if (isNaN(d.getTime())) return '';
  return d.toLocaleString(undefined, {
    month: 'short', day: 'numeric',
    hour: '2-digit', minute: '2-digit',
  });
}

function convLive() {
  try { return typeof pcLive === 'function' && pcLive(); } catch (e) { return false; }
}

function convEscapeHtml(s) {
  return String(s || '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// Render a turn's markdown → sanitized HTML, mirroring the chat surface (GFM
// via `marked`, sanitized by DOMPurify, both from CDN). Falls back to escaped
// plain text with <br/> if either library hasn't loaded. Reuses the shared
// `.md` styles so transcripts match chat replies.
function ConvMarkdown({ text }) {
  const html = React.useMemo(() => {
    const src = text == null ? '' : String(text);
    if (!src) return '';
    const m = window.marked, d = window.DOMPurify;
    if (!m || !d) return convEscapeHtml(src).replace(/\n/g, '<br/>');
    const raw = m.parse(src, { gfm: true, breaks: true });
    return d.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
  }, [text]);
  return <div className="md" dangerouslySetInnerHTML={{ __html: html }} />;
}

function convIsImageMime(m) {
  return typeof m === 'string' && m.indexOf('image/') === 0;
}

function convIsTextMime(m) {
  if (!m) return false;
  if (m.indexOf('text/') === 0) return true;
  if (m.endsWith('+json') || m.endsWith('+xml')) return true;
  return ['application/json', 'application/xml', 'application/javascript',
    'application/yaml', 'application/x-yaml'].includes(m);
}

function convFmtBytes(n) {
  if (n == null) return '';
  if (n < 1024) return n + ' B';
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
  return (n / 1024 / 1024).toFixed(1) + ' MB';
}

// ── API ─────────────────────────────────────────────────────────────────────
function convAuthHeaders() {
  return (typeof parcleAuthHeaders === 'function') ? parcleAuthHeaders() : {};
}

async function syncedConvListLive() {
  const res = await fetch(parcleApiBase() + '/personal/synced-conversations', {
    headers: { ...convAuthHeaders() },
  });
  if (res.status === 401 && typeof parcleNotifyUnauthorized === 'function') parcleNotifyUnauthorized();
  if (!res.ok) {
    let detail = '';
    try { detail = (await res.json()).detail || ''; } catch (e) {}
    throw new Error(`GET /personal/synced-conversations → ${res.status}${detail ? ' ' + detail : ''}`);
  }
  const data = await res.json();
  return Array.isArray(data && data.conversations) ? data.conversations : [];
}

async function syncedConvDetailLive(id) {
  const res = await fetch(parcleApiBase() + '/personal/synced-conversations/' + encodeURIComponent(id), {
    headers: { ...convAuthHeaders() },
  });
  if (res.status === 401 && typeof parcleNotifyUnauthorized === 'function') parcleNotifyUnauthorized();
  if (!res.ok) {
    let detail = '';
    try { detail = (await res.json()).detail || ''; } catch (e) {}
    throw new Error(`GET /personal/synced-conversations/${id} → ${res.status}${detail ? ' ' + detail : ''}`);
  }
  return res.json();
}

// Lazy: fetch one content-addressed artifact's bytes on demand. Returns a Blob;
// the caller decides how to render it (image / text / download) by mime.
async function syncedConvArtifactLive(archiveId, sha256) {
  const res = await fetch(
    parcleApiBase() + '/personal/synced-conversations/' + encodeURIComponent(archiveId)
      + '/artifacts/' + encodeURIComponent(sha256),
    { headers: { ...convAuthHeaders() } },
  );
  if (res.status === 401 && typeof parcleNotifyUnauthorized === 'function') parcleNotifyUnauthorized();
  if (!res.ok) throw new Error(`GET artifact ${String(sha256).slice(0, 8)} → ${res.status}`);
  return res.blob();
}

// Manually retry a *failed* memory projection. Backend only permits this while
// the conversation is in the `failed` state (409 otherwise) and flips it to
// `ingesting` on success.
async function syncedConvReingestLive(id) {
  const res = await fetch(
    parcleApiBase() + '/personal/synced-conversations/' + encodeURIComponent(id) + '/reingest',
    { method: 'POST', headers: { ...convAuthHeaders() } },
  );
  if (res.status === 401 && typeof parcleNotifyUnauthorized === 'function') parcleNotifyUnauthorized();
  if (!res.ok) {
    let detail = '';
    try { detail = (await res.json()).detail || ''; } catch (e) {}
    throw new Error(`Retry failed → ${res.status}${detail ? ' ' + detail : ''}`);
  }
  return res.json();
}

// ── Mock ──────────────────────────────────────────────────────────────────
const CONV_MOCK_LIST = [
  {
    id: 'trn_mock_1', agent_type: 'claude', title: 'parcle-console',
    project: 'parcle-console', cwd: '/work/parcle/parcle-console',
    git_branch: 'main', device_id: 'dev_mac', native_session_id: 'sess-9f2a',
    has_turns: true, memory_status: 'ingested',
    created_at: '2026-06-14T09:12:00Z', updated_at: '2026-06-14T11:48:00Z',
  },
  {
    id: 'trn_mock_2', agent_type: 'codex', title: 'parcle-knowledge',
    project: 'parcle-knowledge', cwd: '/work/parcle/parcle-knowledge',
    git_branch: 'feat/transcript-sync', device_id: 'dev_mac', native_session_id: 'sess-71bc',
    has_turns: true, memory_status: 'failed',
    created_at: '2026-06-13T16:00:00Z', updated_at: '2026-06-13T17:20:00Z',
  },
  {
    id: 'trn_mock_3', agent_type: 'cursor', title: 'website',
    project: 'website', cwd: '/work/site', git_branch: null,
    device_id: 'dev_linux', native_session_id: 'sess-22de',
    has_turns: false, memory_status: 'pending',
    created_at: '2026-06-12T08:00:00Z', updated_at: '2026-06-12T08:05:00Z',
  },
];

const CONV_MOCK_DETAIL = {
  trn_mock_1: {
    turns: [
      { seq: 1, role: 'user', timestamp: '2026-06-14T09:12:00Z', blocks: [
        { type: 'text', text: 'Add a **conversations** view to the personal frontend.' },
      ] },
      { seq: 2, role: 'assistant', timestamp: '2026-06-14T09:13:00Z', blocks: [
        { type: 'thinking', text: 'Mirror the memory page: master-detail, fetch turns on select.' },
        { type: 'text', text: "I'll add a master-detail page and render each transcript's turns." },
        { type: 'tool_call', name: 'Edit', tool_input: { file: 'personal_conversations.jsx', action: 'create' } },
        { type: 'tool_result', name: 'Edit', output: 'personal_conversations.jsx created (8.4 KB)' },
      ] },
      { seq: 3, role: 'assistant', timestamp: '2026-06-14T11:48:00Z', blocks: [
        { type: 'text', text: 'Result screenshot and the new file:' },
        { type: 'image', path: 'screenshot.png', artifact: { sha256: 'img1mocksha256', size: 20480, mime: 'image/png' } },
        { type: 'file', path: 'personal_conversations.jsx', artifact: {
          sha256: 'file1mocksha256', size: 8421, mime: 'text/jsx',
          inline_text: '// personal_conversations.jsx\nfunction PersonalConversations() { /* … */ }',
        } },
      ] },
    ],
  },
  trn_mock_2: {
    turns: [
      { seq: 1, role: 'user', timestamp: '2026-06-13T16:00:00Z', blocks: [
        { type: 'text', text: 'Wire up the transcript sync ingestion.' },
      ] },
      { seq: 2, role: 'assistant', timestamp: '2026-06-13T17:20:00Z', blocks: [
        { type: 'text', text: 'Ingesting parsed turns into the canonical conversation store.' },
        { type: 'tool_result', name: 'bash', status: 'error', output: 'ConnectionRefusedError: localhost:5432' },
      ] },
    ],
  },
};

function syncedConvDetailMock(id) {
  const conversation = CONV_MOCK_LIST.find(c => c.id === id) || null;
  const extra = CONV_MOCK_DETAIL[id] || { turns: [] };
  return new Promise(resolve => setTimeout(() => resolve({
    conversation, turns: extra.turns,
  }), 300));
}

function syncedConvArtifactMock(artifact) {
  return new Promise(resolve => setTimeout(() => {
    if (convIsImageMime(artifact.mime)) {
      const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="280" height="140">'
        + '<rect width="100%" height="100%" fill="#e5e7eb"/>'
        + '<text x="50%" y="50%" text-anchor="middle" dy=".3em" font-family="sans-serif" font-size="14" fill="#6b7280">mock image</text></svg>';
      resolve(new Blob([svg], { type: 'image/svg+xml' }));
    } else {
      resolve(new Blob(['mock artifact bytes for ' + artifact.sha256], { type: artifact.mime || 'text/plain' }));
    }
  }, 350));
}

function syncedConvListMock() {
  return new Promise(resolve => setTimeout(() => resolve(CONV_MOCK_LIST.map(c => ({ ...c }))), 400));
}

// Mock retry: simulate a quick successful projection so a follow-up poll sees
// the conversation flip from `ingesting` → `ingested`.
function syncedConvReingestMock(id) {
  return new Promise(resolve => setTimeout(() => {
    const item = CONV_MOCK_LIST.find(c => c.id === id);
    if (item) item.memory_status = 'ingested';
    resolve({ ok: true });
  }, 800));
}

// ── Page ────────────────────────────────────────────────────────────────────
function PersonalConversations() {
  const [list, setList] = useConvState([]);
  const [loading, setLoading] = useConvState(true);
  const [error, setError] = useConvState(null);
  const [selectedId, setSelectedId] = useConvState(null);

  // `silent` polls (driven by the auto-refresh below) reload the list without
  // flipping the page-level loading/error UI, so an `ingesting` conversation
  // settling to `ingested`/`failed` doesn't flash the spinner or clobber the view.
  const loadList = async ({ silent } = {}) => {
    if (!silent) { setLoading(true); setError(null); }
    try {
      const data = convLive() ? await syncedConvListLive() : await syncedConvListMock();
      setList(data);
    } catch (e) {
      if (!silent) setError((e && e.message) || String(e));
    } finally {
      if (!silent) setLoading(false);
    }
  };

  useConvEffect(() => { loadList(); }, []);

  // Patch a single conversation in place (optimistic status updates).
  const updateConv = (id, patch) =>
    setList(prev => prev.map(c => (c.id === id ? { ...c, ...patch } : c)));

  // Retry a failed memory projection: flip to `ingesting` optimistically (which
  // also kicks off the poll below), then call the backend. Roll back to `failed`
  // and rethrow on error so the detail pane can surface it.
  const retryConv = async (id) => {
    updateConv(id, { memory_status: 'ingesting' });
    try {
      if (convLive()) await syncedConvReingestLive(id);
      else await syncedConvReingestMock(id);
    } catch (e) {
      updateConv(id, { memory_status: 'failed' });
      throw e;
    }
  };

  // While any conversation is `ingesting`, poll the list so the projection's
  // final state lands without a manual refresh. Stops once nothing is in flight.
  const anyIngesting = list.some(c => c.memory_status === 'ingesting');
  useConvEffect(() => {
    if (!anyIngesting) return undefined;
    const t = setInterval(() => { loadList({ silent: true }); }, 5000);
    return () => clearInterval(t);
  }, [anyIngesting]);

  const selected = selectedId ? list.find(c => c.id === selectedId) : null;

  return (
    <div style={{
      height: '100%', display: 'flex', flexDirection: 'column',
      padding: '32px 32px 24px', maxWidth: 1040, margin: '0 auto',
    }}>
      {/* Title */}
      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 8, flexShrink: 0 }}>
        <div style={{ flex: 1 }}>
          <h1 className="t-h1" style={{ margin: 0 }}>Conversations</h1>
          <div className="text-secondary t-body" style={{ marginTop: 4 }}>
            Agent sessions your devices synced from Claude Code, Codex, and Cursor.
          </div>
        </div>
        <button className="btn sm" onClick={() => loadList()} disabled={loading}
          style={loading ? { opacity: 0.6 } : {}}>
          {loading
            ? <><Icons.Spinner size={13} style={{ animation: 'parcle-spin .8s linear infinite' }} /> Loading</>
            : <><Icons.Link size={13} /> Refresh</>}
        </button>
      </div>

      {/* Error */}
      {error && (
        <div style={{
          marginTop: 20, padding: '14px 16px',
          border: '1px solid var(--error-bg)', borderRadius: 12,
          background: 'var(--surface)', display: 'flex', alignItems: 'center', gap: 10,
        }}>
          <Icons.AlertTri size={15} style={{ color: 'var(--error)', flexShrink: 0 }} />
          <div>
            <div className="t-body" style={{ fontWeight: 500 }}>Couldn’t load conversations</div>
            <div className="mono-sm mono text-secondary" style={{ marginTop: 2 }}>{error}</div>
          </div>
        </div>
      )}

      {/* Loading (initial) */}
      {loading && list.length === 0 && !error && (
        <div style={{ marginTop: 28, display: 'flex', alignItems: 'center', gap: 10, color: 'var(--text-secondary)' }}>
          <Icons.Spinner size={15} style={{ animation: 'parcle-spin .8s linear infinite' }} />
          <span className="mono-sm mono">Loading synced conversations…</span>
        </div>
      )}

      {/* Empty */}
      {!loading && !error && list.length === 0 && (
        <div className="t-small text-tertiary" style={{
          marginTop: 24, padding: 18, border: '1px solid var(--border-subtle)', borderRadius: 12,
          background: 'var(--surface)',
        }}>
          No conversations synced yet. Enable a coding agent in Team Sync and your sessions will appear here.
        </div>
      )}

      {/* Master-detail. The row fills the remaining height and each pane scrolls
          independently, so the detail stays in view no matter how far down the
          list you scroll to pick an old conversation. */}
      {!error && list.length > 0 && (
        <div style={{ flex: 1, minHeight: 0, marginTop: 24, display: 'flex', gap: 16 }}>
          {/* List pane — scrolls on its own */}
          <div style={{
            width: 360, flexShrink: 0, overflowY: 'auto', paddingRight: 4,
            display: 'flex', flexDirection: 'column', gap: 8,
          }}>
            {list.map(conv => (
              <ConversationCard
                key={conv.id}
                conv={conv}
                selected={conv.id === selectedId}
                onClick={() => setSelectedId(conv.id)}
              />
            ))}
          </div>

          {/* Detail pane — scrolls on its own; remounts per selection so a long
              prior transcript doesn't leave the next one scrolled partway. */}
          <div key={selectedId || 'none'} style={{ flex: 1, minWidth: 0, overflowY: 'auto' }}>
            {selected
              ? <ConversationDetail conv={selected} onRetry={retryConv} />
              : <div className="t-small text-tertiary" style={{
                  padding: 40, border: '1px dashed var(--border-subtle)', borderRadius: 12,
                  textAlign: 'center', background: 'var(--surface)',
                }}>
                  Select a conversation to view its transcript.
                </div>}
          </div>
        </div>
      )}
    </div>
  );
}

// ── List card ─────────────────────────────────────────────────────────────
function ConversationCard({ conv, selected, onClick }) {
  const meta = convAgentMeta(conv.agent_type);
  return (
    <button onClick={onClick} style={{
      textAlign: 'left', width: '100%',
      padding: 14, borderRadius: 12, cursor: 'pointer',
      border: `1px solid ${selected ? 'var(--accent)' : 'var(--border-subtle)'}`,
      background: selected ? 'var(--accent-muted)' : 'var(--surface)',
      display: 'flex', flexDirection: 'column', gap: 8,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span style={{
          display: 'inline-flex', alignItems: 'center', gap: 6,
          padding: '2px 8px', borderRadius: 6,
          background: 'var(--bg)', border: '1px solid var(--border-subtle)',
        }}>
          <span style={{ width: 7, height: 7, borderRadius: 2, background: meta.color, flexShrink: 0 }} />
          <span className="mono-sm mono" style={{ color: 'var(--text-secondary)' }}>{meta.label}</span>
        </span>
        <ConvMemoryStatus status={conv.memory_status} compact />
        <div style={{ flex: 1 }} />
        <span className="mono-sm mono text-tertiary" style={{ flexShrink: 0 }}>{convFmtTime(conv.updated_at)}</span>
      </div>
      <div className="t-body" style={{
        fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
      }}>{conv.title || conv.native_session_id}</div>
      {(conv.git_branch || conv.cwd) && (
        <div className="mono-sm mono text-tertiary" style={{
          display: 'flex', alignItems: 'center', gap: 8, minWidth: 0,
        }}>
          {conv.git_branch && (
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
              <Icons.Link size={11} /> {conv.git_branch}
            </span>
          )}
          {conv.cwd && (
            <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{conv.cwd}</span>
          )}
        </div>
      )}
    </button>
  );
}

// ── Detail pane ──────────────────────────────────────────────────────────
function ConversationDetail({ conv, onRetry }) {
  const [detail, setDetail] = useConvState(null);
  const [loading, setLoading] = useConvState(false);
  const [error, setError] = useConvState(null);
  const [retryErr, setRetryErr] = useConvState(null);

  // onRetry flips status to `ingesting` synchronously, so the failed block
  // unmounts immediately; on error it rolls back to `failed` and rethrows, and
  // retryErr (held here, not in the block) survives to be shown on remount.
  const doRetry = async () => {
    setRetryErr(null);
    try { await onRetry(conv.id); }
    catch (e) { setRetryErr((e && e.message) || String(e)); }
  };

  useConvEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);
    setDetail(null);
    (async () => {
      try {
        const data = convLive() ? await syncedConvDetailLive(conv.id) : await syncedConvDetailMock(conv.id);
        if (!cancelled) setDetail(data);
      } catch (e) {
        if (!cancelled) setError((e && e.message) || String(e));
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => { cancelled = true; };
  }, [conv.id]);

  const meta = convAgentMeta(conv.agent_type);
  const turns = (detail && detail.turns) || [];

  return (
    <div style={{
      border: '1px solid var(--border-subtle)', borderRadius: 12,
      background: 'var(--surface)',
    }}>
      {/* Header — sticks to the top of the detail pane while turns scroll. */}
      <div style={{
        padding: 16, borderBottom: '1px solid var(--border-subtle)',
        position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1,
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span style={{ width: 8, height: 8, borderRadius: 2, background: meta.color, flexShrink: 0 }} />
          <span className="t-body" style={{ fontWeight: 600 }}>{conv.title || conv.native_session_id}</span>
          <div style={{ flex: 1 }} />
          <ConvMemoryStatus status={conv.memory_status} />
          <span className="mono-sm mono text-tertiary">{meta.label}</span>
        </div>
        <div className="mono-sm mono text-tertiary" style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 12 }}>
          {conv.git_branch && <span><Icons.Link size={11} /> {conv.git_branch}</span>}
          {conv.cwd && <span>{conv.cwd}</span>}
          <span>updated {convFmtTime(conv.updated_at)}</span>
        </div>
        {conv.memory_status === 'failed' && (
          <div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
            <button className="btn sm" onClick={doRetry}>
              <Icons.Refresh size={13} /> Retry ingest
            </button>
            <span className="t-small text-tertiary">Memory projection failed. Retry to re-run it.</span>
            {retryErr && <span className="mono-sm mono" style={{ color: 'var(--error)' }}>{retryErr}</span>}
          </div>
        )}
      </div>

      {/* Body */}
      <div style={{ padding: 16 }}>
        {loading && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--text-secondary)' }}>
            <Icons.Spinner size={15} style={{ animation: 'parcle-spin .8s linear infinite' }} />
            <span className="mono-sm mono">Loading transcript…</span>
          </div>
        )}

        {error && !loading && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
            <Icons.AlertTri size={15} style={{ color: 'var(--error)', flexShrink: 0 }} />
            <div className="mono-sm mono text-secondary">{error}</div>
          </div>
        )}

        {!loading && !error && turns.length === 0 && (
          <div className="t-small text-tertiary">
            This conversation has no turns yet — its transcript is still being ingested.
          </div>
        )}

        {!loading && !error && turns.length > 0 && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
            {turns.map((turn, i) => (
              <ConversationTurn key={turn.seq != null ? turn.seq : i} turn={turn} archiveId={conv.id} />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// ── Single transcript turn (renders its canonical blocks) ──────────────────
function ConversationTurn({ turn, archiveId }) {
  const role = CONV_ROLE_META[turn.role] || { label: turn.role || 'Turn' };
  const s = convRoleStyle(turn.role);
  const blocks = turn.blocks || [];
  return (
    <div style={{
      border: `1px solid ${s.border}`,
      borderLeft: s.bar ? `3px solid ${s.bar}` : `1px solid ${s.border}`,
      borderRadius: 10,
      background: s.bg,
      padding: 12,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
        <span className="t-micro" style={{
          color: s.label, fontWeight: 600,
          textTransform: 'uppercase', letterSpacing: '0.04em',
        }}>
          {role.label}
        </span>
        <div style={{ flex: 1 }} />
        {turn.timestamp && <span className="mono-sm mono text-tertiary">{convFmtTime(turn.timestamp)}</span>}
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
        {blocks.map((block, i) => <ConversationBlock key={i} block={block} archiveId={archiveId} />)}
      </div>
    </div>
  );
}

// ── One content block within a turn ────────────────────────────────────────
function ConversationBlock({ block, archiveId }) {
  const type = block && block.type;
  if (type === 'text') return block.text ? <ConvMarkdown text={block.text} /> : null;
  if (type === 'thinking') return <ThinkingBlock text={block.text || ''} />;
  if (type === 'tool_call') return <ToolCallBlock name={block.name} input={block.tool_input} />;
  if (type === 'tool_result') return <ToolResultBlock block={block} archiveId={archiveId} />;
  if (type === 'image' || type === 'file') return <FileBlock block={block} archiveId={archiveId} />;
  // Unknown block kind — best-effort text, else skip.
  return block && block.text ? <ConvMarkdown text={block.text} /> : null;
}

// Thinking — collapsed by default, dimmed when shown.
function ThinkingBlock({ text }) {
  const [open, setOpen] = useConvState(false);
  if (!text) return null;
  return (
    <div style={{
      border: '1px dashed var(--border-subtle)', borderRadius: 8,
      background: 'var(--bg)', padding: '8px 10px',
    }}>
      <button onClick={() => setOpen(v => !v)} style={{
        display: 'flex', alignItems: 'center', gap: 6, width: '100%',
        background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer',
      }}>
        <Icons.Sparkle size={12} />
        <span className="t-micro" style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Thinking</span>
        <div style={{ flex: 1 }} />
        <span className="mono-sm mono">{open ? '−' : '+'}</span>
      </button>
      {open && (
        <div style={{ marginTop: 8, opacity: 0.72, fontStyle: 'italic' }}>
          <ConvMarkdown text={text} />
        </div>
      )}
    </div>
  );
}

// Tool call — name + collapsible input.
function ToolCallBlock({ name, input }) {
  const [open, setOpen] = useConvState(false);
  const hasInput = input != null && !(typeof input === 'object' && Object.keys(input).length === 0);
  let inputStr = '';
  try { inputStr = typeof input === 'string' ? input : JSON.stringify(input, null, 2); }
  catch (e) { inputStr = String(input); }
  return (
    <div style={{ border: '1px solid var(--border-subtle)', borderRadius: 8, background: 'var(--bg)', overflow: 'hidden' }}>
      <button onClick={() => hasInput && setOpen(v => !v)} style={{
        display: 'flex', alignItems: 'center', gap: 8, width: '100%',
        padding: '6px 10px', background: 'transparent',
        cursor: hasInput ? 'pointer' : 'default',
      }}>
        <Icons.Code size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
        <span className="mono-sm mono" style={{ color: 'var(--text-secondary)' }}>{name || 'tool'}</span>
        <div style={{ flex: 1 }} />
        {hasInput && <span className="mono-sm mono text-tertiary">{open ? 'hide' : 'input'}</span>}
      </button>
      {open && hasInput && (
        <pre className="mono-sm mono" style={{
          margin: 0, padding: '8px 10px', borderTop: '1px solid var(--border-subtle)',
          whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--text-secondary)',
          maxHeight: 280, overflow: 'auto',
        }}>{inputStr}</pre>
      )}
    </div>
  );
}

// Tool result — inline output (error-styled if failed) + optional artifact.
function ToolResultBlock({ block, archiveId }) {
  const isError = block.status === 'error';
  // output is declared str|None, but coerce defensively so a structured value
  // never crashes the render.
  let output = block.output;
  if (output != null && typeof output !== 'string') {
    try { output = JSON.stringify(output, null, 2); } catch (e) { output = String(output); }
  }
  return (
    <div style={{
      border: `1px solid ${isError ? 'var(--error-bg)' : 'var(--border-subtle)'}`,
      borderRadius: 8, background: 'var(--bg)', overflow: 'hidden',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
        <Icons.ArrowR size={12} style={{ color: isError ? 'var(--error)' : 'var(--text-tertiary)', flexShrink: 0 }} />
        <span className="mono-sm mono" style={{ color: isError ? 'var(--error)' : 'var(--text-secondary)' }}>
          {block.name || 'result'}{isError ? ' · error' : ''}
        </span>
      </div>
      {output != null && output !== '' && (
        <pre className="mono-sm mono" style={{
          margin: 0, padding: '8px 10px', borderTop: '1px solid var(--border-subtle)',
          whiteSpace: 'pre-wrap', wordBreak: 'break-word',
          color: isError ? 'var(--error)' : 'var(--text-secondary)',
          maxHeight: 280, overflow: 'auto',
        }}>{output}</pre>
      )}
      {block.artifact && (
        <div style={{ padding: 8, borderTop: '1px solid var(--border-subtle)' }}>
          <ArtifactRefView artifact={block.artifact} archiveId={archiveId} label={block.path || block.name} />
        </div>
      )}
    </div>
  );
}

// Image / file block — path + lazy artifact.
function FileBlock({ block, archiveId }) {
  if (!block.artifact) {
    return block.path ? (
      <div className="mono-sm mono text-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <Icons.Doc size={13} style={{ color: 'var(--text-tertiary)' }} /> {block.path}
      </div>
    ) : null;
  }
  return <ArtifactRefView artifact={block.artifact} archiveId={archiveId} label={block.path} />;
}

// Lazy artifact viewer. Small inline text renders immediately; everything else
// is a collapsed chip whose bytes are fetched ONLY when the user clicks Open.
function ArtifactRefView({ artifact, archiveId, label }) {
  const [state, setState] = useConvState('idle');   // idle | loading | loaded | error
  const [blobUrl, setBlobUrl] = useConvState(null);
  const [text, setText] = useConvState(null);
  const [err, setErr] = useConvState(null);

  React.useEffect(() => () => { if (blobUrl) URL.revokeObjectURL(blobUrl); }, [blobUrl]);

  const mime = artifact.mime;
  const isImage = convIsImageMime(mime);
  const isText = convIsTextMime(mime);
  const inline = artifact.inline_text;
  const name = label || (artifact.sha256 ? String(artifact.sha256).slice(0, 12) : 'artifact');

  const open = async () => {
    if (state === 'loading' || state === 'loaded') return;
    setState('loading');
    setErr(null);
    try {
      const blob = convLive()
        ? await syncedConvArtifactLive(archiveId, artifact.sha256)
        : await syncedConvArtifactMock(artifact);
      if (isImage) setBlobUrl(URL.createObjectURL(blob));
      else if (isText) setText(await blob.text());
      else setBlobUrl(URL.createObjectURL(blob));
      setState('loaded');
    } catch (e) {
      setErr((e && e.message) || String(e));
      setState('error');
    }
  };

  const preStyle = {
    margin: 0, padding: '8px 10px', borderTop: '1px solid var(--border-subtle)',
    whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--text-secondary)',
    maxHeight: 320, overflow: 'auto',
  };

  return (
    <div style={{ border: '1px solid var(--border-subtle)', borderRadius: 8, background: 'var(--surface)', overflow: 'hidden' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px' }}>
        <Icons.Doc size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
        <span className="mono-sm mono" style={{
          flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          color: 'var(--text-secondary)',
        }}>{name}</span>
        {mime && <span className="mono-sm mono text-tertiary" style={{ flexShrink: 0 }}>{mime}</span>}
        {artifact.size != null && <span className="mono-sm mono text-tertiary" style={{ flexShrink: 0 }}>{convFmtBytes(artifact.size)}</span>}
        {inline == null && state !== 'loaded' && (
          <button className="btn sm" onClick={open} disabled={state === 'loading'} style={{ flexShrink: 0 }}>
            {state === 'loading'
              ? <><Icons.Spinner size={12} style={{ animation: 'parcle-spin .8s linear infinite' }} /> Loading</>
              : 'Open'}
          </button>
        )}
      </div>

      {inline != null && <pre className="mono-sm mono" style={preStyle}>{inline}</pre>}
      {state === 'error' && (
        <div className="mono-sm mono" style={{ padding: '8px 10px', borderTop: '1px solid var(--border-subtle)', color: 'var(--error)' }}>{err}</div>
      )}
      {state === 'loaded' && isImage && blobUrl && (
        <div style={{ padding: 10, borderTop: '1px solid var(--border-subtle)' }}>
          <img src={blobUrl} alt={name} style={{ maxWidth: '100%', borderRadius: 6, display: 'block' }} />
        </div>
      )}
      {state === 'loaded' && isText && text != null && <pre className="mono-sm mono" style={preStyle}>{text}</pre>}
      {state === 'loaded' && !isImage && !isText && blobUrl && (
        <div style={{ padding: '8px 10px', borderTop: '1px solid var(--border-subtle)' }}>
          <a className="btn sm" href={blobUrl} download={name}>Download</a>
        </div>
      )}
    </div>
  );
}

Object.assign(window, { PersonalConversations });
