<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cryohub</title>
<link rel="icon" type="image/svg+xml" href="/assets/mark.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..700&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/web.css">
</head>
<body>

<div class="topbar">
  <div class="brand">
    <img src="/assets/logo.svg" class="logo" alt="cryochamber">
  </div>
</div>

<div class="app">

  <aside class="rail">
    <div class="rail-head">
      <span class="title">Chambers</span>
      <span class="count" id="rail-count">0</span>
      <button class="rail-add" id="chamber-new" title="New chamber">+</button>
    </div>
    <div class="rail-list" id="chamber-list"></div>
    <div class="rail-foot">
      <span>auto-refresh · on</span>
      <span><button id="refresh">⟳</button></span>
    </div>
  </aside>

  <section class="center" id="pane">
    <div class="pane-empty">
      <div>Pick a chamber from the rail</div>
    </div>
  </section>

  <aside class="drawer" id="drawer" hidden>
    <div class="drawer-tabs">
      <div class="drawer-tab active" data-tab="todos">Todos<span class="tab-count" id="todos-count">0</span></div>
      <div class="drawer-tab" data-tab="sync">Sync</div>
      <div class="drawer-tab" data-tab="settings">Settings</div>
      <div class="drawer-tab" data-tab="log">Log</div>
    </div>
    <div class="drawer-body" data-panel="todos" id="panel-todos"></div>
    <div class="drawer-body hidden" data-panel="sync" id="panel-sync"></div>
    <div class="drawer-body hidden" data-panel="settings" id="panel-settings"></div>
    <div class="drawer-body hidden" data-panel="log" id="panel-log"></div>
  </aside>

</div>

<div id="toast" class="toast"></div>

<div class="modal-backdrop" id="modal-backdrop" hidden>
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
    <h3 id="modal-title">New chamber</h3>
    <div class="modal-field">
      <label for="modal-name-input">Name</label>
      <input id="modal-name-input" type="text" placeholder="my-chamber" autocomplete="off">
    </div>
    <details class="modal-provider-details" id="modal-provider-details">
      <summary>API key provider</summary>
      <div class="modal-field">
        <label for="modal-provider-input">Provider</label>
        <input id="modal-provider-input" list="modal-provider-options" type="text" placeholder="provider-id" autocomplete="off">
        <datalist id="modal-provider-options"></datalist>
      </div>
      <div class="modal-field">
        <label for="modal-model-input">Model</label>
        <input id="modal-model-input" list="modal-model-options" type="text" placeholder="model-id" autocomplete="off">
        <datalist id="modal-model-options"></datalist>
      </div>
      <div class="modal-field">
        <label for="modal-api-key-input">API key</label>
        <input id="modal-api-key-input" type="password" placeholder="sk-..." autocomplete="off">
      </div>
      <div class="modal-model-status" id="modal-model-status"></div>
    </details>
    <div class="modal-error" id="modal-error" hidden></div>
    <div class="modal-actions">
      <button id="modal-cancel" class="modal-btn">Cancel</button>
      <button id="modal-create" class="modal-btn modal-btn-primary">Create</button>
    </div>
  </div>
</div>

<script>
(function() {
  const state = {
    chambers: [],
    currentId: null,
    detailSeq: 0,
    lifecyclePending: {},
    chamberHistoryOpen: false,
  };

  function urlChamberId() {
    const m = window.location.pathname.match(/^\/c\/(.+)$/);
    return m ? m[1] : null;
  }

  async function fetchJSON(url, opts) {
    const res = await fetch(url, opts);
    let data = null;
    try {
      data = await res.json();
    } catch (e) {
      if (res.ok) throw e;
    }
    if (!res.ok) throw new Error((data && data.message) || `${url}: ${res.status}`);
    if (data && data.ok === false) throw new Error(data.message || 'Request failed');
    return data;
  }

  function toast(msg, kind) {
    const el = document.getElementById('toast');
    el.textContent = msg;
    el.className = 'toast' + (kind === 'error' ? ' error' : '');
    el.style.display = 'block';
    setTimeout(() => { el.style.display = 'none'; }, 4000);
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  }

  function hash32(s) {
    let h = 0x811c9dc5 >>> 0;
    for (let i = 0; i < s.length; i++) {
      h ^= s.charCodeAt(i);
      h = Math.imul(h, 0x01000193) >>> 0;
    }
    return h.toString(36);
  }

  function statusClass(entry) {
    if (entry.config_error) return 'error';
    if (entry.agent_running && entry.running) return 'running-active';
    if (entry.completed) return 'complete';
    if (entry.running) return 'running';
    return 'stopped';
  }

  // Server emits wake/scheduled times as 'YYYY-MM-DDTHH:MM' in local time.
  // Browsers parse date-time strings without an offset as local time, so
  // `new Date(iso)` matches the daemon's `chrono::Local` clock.
  function formatWakeRelative(iso, nowMs) {
    if (!iso) return null;
    const target = new Date(iso);
    const t = target.getTime();
    if (!Number.isFinite(t)) return null;
    const diffMin = Math.round((t - (nowMs ?? Date.now())) / 60000);
    let text;
    if (diffMin <= -60 * 24) text = `${Math.round(-diffMin / (60 * 24))}d ago`;
    else if (diffMin <= -60)  text = `${Math.round(-diffMin / 60)}h ago`;
    else if (diffMin < 0)     text = `${-diffMin}m ago`;
    else if (diffMin < 1)     text = 'now';
    else if (diffMin < 60)    text = `in ${diffMin}m`;
    else if (diffMin < 60 * 24) {
      const h = Math.floor(diffMin / 60);
      const m = diffMin % 60;
      text = m > 0 ? `in ${h}h ${m}m` : `in ${h}h`;
    } else text = `in ${Math.floor(diffMin / (60 * 24))}d`;
    return { text, soon: diffMin >= 0 && diffMin <= 60 };
  }

  // Renders a wake-time element with a live-updating relative label
  // (`in 5m`). The absolute ISO is stashed on the element so the 30s
  // ticker can re-render in place without re-fetching, and exposed via
  // `title` so operators can still see the exact time on hover.
  function applyWake(el, iso, prefix) {
    prefix = prefix || '';
    if (!iso) {
      delete el.dataset.wakeIso;
      delete el.dataset.wakePrefix;
      el.removeAttribute('title');
      el.classList.remove('soon');
      el.textContent = '';
      return;
    }
    el.dataset.wakeIso = iso;
    if (prefix) el.dataset.wakePrefix = prefix;
    else delete el.dataset.wakePrefix;
    el.title = iso;
    const rel = formatWakeRelative(iso);
    el.textContent = prefix + (rel ? rel.text : iso);
    el.classList.toggle('soon', !!(rel && rel.soon));
  }

  function refreshWakeRelatives() {
    document.querySelectorAll('[data-wake-iso]').forEach(el => {
      const iso = el.dataset.wakeIso;
      const prefix = el.dataset.wakePrefix || '';
      const rel = formatWakeRelative(iso);
      if (!rel) return;
      el.textContent = prefix + rel.text;
      el.classList.toggle('soon', rel.soon);
    });
  }

  function buildChamberRow(c) {
    const row = document.createElement('div');
    row.className = 'chamber' + (c.id === state.currentId ? ' active' : '');
    row.dataset.id = c.id;
    row.title = c.path || c.path_hint || c.name;

    const row1 = document.createElement('div');
    row1.className = 'chamber-row1';
    const dot = document.createElement('span');
    dot.className = 'status-dot ' + statusClass(c);
    const name = document.createElement('span');
    name.className = 'chamber-name';
    name.textContent = c.name;
    name.title = c.path || c.path_hint || c.name;
    row1.appendChild(dot);
    row1.appendChild(name);
    if (c.has_open_question) {
      const badge = document.createElement('span');
      badge.className = 'question-badge';
      badge.textContent = '?';
      badge.title = 'Open question — agent is waiting on you';
      row1.appendChild(badge);
    }
    row.appendChild(row1);

    if (c.next_wake_display) {
      const meta = document.createElement('div');
      meta.className = 'chamber-meta';
      const when = document.createElement('span');
      when.className = c.running ? 'when' : 'when muted';
      applyWake(when, c.next_wake_display);
      meta.appendChild(when);
      row.appendChild(meta);
    }

    row.addEventListener('click', () => selectChamber(c.id));
    return row;
  }

  function renderRail() {
    const list = document.getElementById('chamber-list');
    list.innerHTML = '';
    // Split active vs completed: completed plans get folded into a
    // collapsible `<details>` section, same pattern as the TODO panel's
    // "History" fold. Keeps the rail focused on chambers that still need
    // attention.
    const active = [];
    const completed = [];
    for (const c of state.chambers) {
      // Group by completion alone. The daemon can still be winding down
      // (running=true) right after `hibernate --complete`; `statusClass` and
      // the header glyph already prioritise "completed" in that window, so
      // the fold must too — otherwise the rail shows the chamber as active
      // while every other signal says it's done.
      if (c.completed) completed.push(c);
      else active.push(c);
    }
    const runningFirst = (a, b) => {
      const rank = e => (e.running ? 0 : 1);
      const ra = rank(a), rb = rank(b);
      if (ra !== rb) return ra - rb;
      return a.name.localeCompare(b.name);
    };
    active.sort(runningFirst);
    completed.sort((a, b) => a.name.localeCompare(b.name));

    for (const c of active) list.appendChild(buildChamberRow(c));

    if (completed.length) {
      const details = document.createElement('details');
      details.className = 'chamber-history';
      // Keep the fold open if (a) the user had it open last render, or (b)
      // the currently-selected chamber lives inside it — otherwise clicking
      // Complete on the active chamber would silently fold the selection
      // away and the operator loses visual feedback of what's selected.
      const selectedIsInside = completed.some(c => c.id === state.currentId);
      if (state.chamberHistoryOpen || selectedIsInside) details.open = true;
      details.addEventListener('toggle', () => {
        state.chamberHistoryOpen = details.open;
      });
      const summary = document.createElement('summary');
      summary.textContent = `Completed (${completed.length})`;
      details.appendChild(summary);
      for (const c of completed) details.appendChild(buildChamberRow(c));
      list.appendChild(details);
    }

    document.getElementById('rail-count').textContent = state.chambers.length;
  }

  async function loadChambers() {
    state.chambers = await fetchJSON('/api/chambers');
    renderRail();
  }

  async function refreshChamberIndex(options) {
    const silent = !!(options && options.silent);
    try {
      state.chambers = await fetchJSON('/api/chambers/refresh', { method: 'POST' });
      renderRail();
    } catch (e) {
      if (!silent) toast(e.message, 'error');
    }
  }

  let railRefreshTimer = null;
  function scheduleRailRefresh(chamberId) {
    if (railRefreshTimer) clearTimeout(railRefreshTimer);
    railRefreshTimer = setTimeout(async () => {
      railRefreshTimer = null;
      try {
        await loadChambers();
        if (state.currentId === chamberId) {
          await updateDetail(chamberId, { status: true, todos: true, sync: true });
        }
      } catch (e) {
        toast(e.message, 'error');
      }
    }, 250);
  }

  let view = null;

  function newView() {
    return {
      chamberId: null,
      headerName: null,
      headerMeta: null,
      headerTaskLine: null,
      planCompleteBox: null,
      lifecycleBtns: null,
      panels: { todos: null, sync: null, settings: null, log: null },
      syncEl: null,
      notesEl: null,
      notesLastHtml: null,
      planEl: null,
      planLastHtml: null,
      settingsEl: null,
      settingsLastSig: null,
      logEl: null,
      messagesBodyEl: null,
      planBodyEl: null,
      notesBodyEl: null,
      messagesUnreadEl: null,
      lastMsgKey: null,
      lastTodoSig: null,
      lastSyncSig: null,
      lastLifecycleSig: null,
      msgBox: null,
      allMessages: [],
      historyEl: null,
      historyCount: 0,
    };
  }

  function btn(label, onClick, cls, pending) {
    const b = document.createElement('button');
    b.textContent = label;
    if (cls) b.className = cls;
    b.disabled = !!pending;
    b.addEventListener('click', onClick);
    return b;
  }

  function statusDotChar(entry) {
    if (entry.config_error) return '✗';
    if (entry.completed) return '✓';
    return entry.running ? '●' : '○';
  }

  function formatSessionSummary(session, summary) {
    const text = String(summary || '').trim();
    if (!text) return '';
    const body = text.replace(/^Session\s+#?\d+\s*:\s*/i, '').trim();
    const prefix = Number(session) > 0 ? `Session ${session}: ` : '';
    return prefix + (body || text);
  }

  function buildHead(pane, entry, status) {
    const head = document.createElement('div');
    head.className = 'thread-head';

    const left = document.createElement('div');
    left.className = 'left';
    const title = document.createElement('div');
    title.className = 'title';
    const name = document.createElement('span');
    name.className = 'name';
    name.textContent = entry.name;
    const session = document.createElement('span');
    session.className = 'session';
    left.appendChild(title);
    title.appendChild(name);
    title.appendChild(session);

    const taskRow = document.createElement('div');
    taskRow.className = 'task-row';
    const task = document.createElement('div');
    task.className = 'task';
    taskRow.appendChild(task);
    left.appendChild(taskRow);

    const plan = document.createElement('div');
    plan.className = 'plan-complete';
    plan.style.display = 'none';
    left.appendChild(plan);

    const right = document.createElement('div');
    right.className = 'thread-actions';
    const wake = document.createElement('span');
    wake.className = 'wake-chip';
    const lifecycle = document.createElement('div');
    lifecycle.className = 'lifecycle';
    right.appendChild(wake);
    right.appendChild(lifecycle);

    head.appendChild(left);
    head.appendChild(right);
    pane.appendChild(head);

    view.headerName = name;
    view.headerMeta = session;
    view.headerTaskLine = task;
    view.planCompleteBox = plan;
    view.lifecycleBtns = lifecycle;
    view.wakeChip = wake;

    updateHead(entry, status);
    updateLifecycleButtons(entry);
  }

  function updateHead(entry, status) {
    const dot = statusDotChar(entry);
    view.headerName.textContent = entry.name;
    const summary = status.session_summary || '';
    const displaySummary = formatSessionSummary(status.session, summary);
    view.headerMeta.textContent = displaySummary ? '' : `SESSION #${status.session}`;
    view.headerMeta.style.display = displaySummary ? 'none' : '';
    if (status.completed) {
      view.planCompleteBox.style.display = '';
      view.planCompleteBox.textContent = '✓ Plan complete' +
        (status.completion_summary ? ': ' + status.completion_summary : '');
      view.headerTaskLine.textContent = '';
      view.headerTaskLine.title = '';
    } else {
      view.planCompleteBox.style.display = 'none';
      if (displaySummary) {
        view.headerTaskLine.textContent = displaySummary;
        view.headerTaskLine.title = displaySummary;
      } else {
        view.headerTaskLine.textContent = '';
        view.headerTaskLine.title = '';
      }
    }
    if (entry.running && status.next_wake) {
      view.wakeChip.innerHTML = '';
      const pulse = document.createElement('span');
      pulse.className = 'pulse';
      const txt = document.createElement('span');
      view.wakeChip.appendChild(pulse);
      view.wakeChip.appendChild(txt);
      applyWake(txt, status.next_wake, 'next wake ');
      view.wakeChip.style.display = '';
    } else {
      view.wakeChip.style.display = 'none';
    }
  }

  function updateLifecycleButtons(entry) {
    const pending = state.lifecyclePending[entry.id];
    const sig = `${entry.running}|${!!entry.config_error}|${!!entry.completed}|${!!pending}`;
    if (sig === view.lastLifecycleSig) return;
    view.lastLifecycleSig = sig;
    const btns = view.lifecycleBtns;
    btns.innerHTML = '';
    if (entry.running) {
      btns.appendChild(btn('stop', () => lifecycle(entry.id, 'stop'), '', pending));
    } else if (!entry.config_error && !entry.completed) {
      // A completed plan has nothing to resume — hide Launch. Reset stays
      // available so the operator can archive the run and begin a new plan.
      btns.appendChild(btn('launch', () => lifecycle(entry.id, 'start'), 'primary', pending));
    }
    if (!entry.running && !entry.config_error) {
      btns.appendChild(btn('reset', () => confirmReset(entry.id, entry.name), 'danger', pending));
    }
  }

  function setLifecyclePending(id, pending) {
    if (pending) {
      state.lifecyclePending[id] = true;
    } else {
      delete state.lifecyclePending[id];
    }
    if (view && view.chamberId === id && view.lifecycleBtns) {
      const entry = state.chambers.find(c => c.id === id);
      if (entry) updateLifecycleButtons(entry);
    }
  }

  function confirmReset(id, name) {
    const msg = `Reset "${name}"?\n\nThis will:\n  • stop the daemon if running\n  • archive logs, todo.json, NOTES.md, messages, and timer.json to history/<timestamp>/\n  • leave the chamber stopped — press Launch to begin a new session\n\nThis cannot be undone.`;
    if (window.confirm(msg)) lifecycle(id, 'reset');
  }

  async function lifecycle(id, action) {
    if (state.lifecyclePending[id]) return;
    setLifecyclePending(id, true);
    try {
      const opts = { method: 'POST' };
      if (action === 'wake') {
        opts.headers = { 'Content-Type': 'application/json' };
        opts.body = JSON.stringify({});
      }
      const resp = await fetchJSON(`/api/chambers/${id}/${action}`, opts);
      toast(resp.message || action, resp.ok ? '' : 'error');
      await loadChambers();
      if (state.currentId === id) await selectChamber(id, { push: false });
    } catch (e) {
      toast(e.message, 'error');
    } finally {
      setLifecyclePending(id, false);
    }
  }

  const PAGE_SIZE = 30;

  function messageKey(m) {
    if (m.id) return m.id;
    return `${m.direction}|${m.timestamp}|${m.from}|${hash32(m.body || '')}`;
  }

  function dayKey(ts) {
    return (ts || '').slice(0, 10); // YYYY-MM-DD
  }

  // System notice = lifecycle/runtime information, rendered as centered gray
  // text instead of a conversation bubble.
  function isSystemMessage(m) {
    return (m.direction === 'inbox' && m.from === 'operator') || m.from === 'cryochamber';
  }

  function buildSystemRow(m) {
    const row = document.createElement('div');
    row.className = 'sys-notice';
    row.dataset.key = messageKey(m);
    row.dataset.day = dayKey(m.timestamp);
    if (m.session != null) row.dataset.session = String(m.session);
    const ts = (m.timestamp || '').slice(11, 16);
    row.textContent = [m.from, ts, m.body || ''].filter(Boolean).join(' · ');
    return row;
  }

  function buildMessageRow(m) {
    if (isSystemMessage(m)) return buildSystemRow(m);
    const row = document.createElement('div');
    // outbox = agent (left), inbox = human (right)
    row.className = 'msg ' + (m.direction === 'outbox' ? 'agent' : 'human');
    if (m.is_question) row.classList.add('question');
    row.dataset.key = messageKey(m);
    row.dataset.day = dayKey(m.timestamp);
    if (m.session != null) row.dataset.session = String(m.session);
    const meta = document.createElement('div');
    meta.className = 'msg-meta';
    const from = document.createElement('span');
    from.className = 'from';
    from.textContent = m.direction === 'inbox' ? m.from : (m.from || 'agent');
    const ts = document.createElement('span');
    ts.textContent = (m.timestamp || '').slice(11, 16);
    meta.appendChild(from);
    meta.appendChild(ts);
    const body = document.createElement('div');
    body.className = 'msg-body';
    body.textContent = m.body || '';
    row.appendChild(meta);
    row.appendChild(body);
    return row;
  }

  function buildDayMarker(day) {
    const el = document.createElement('div');
    el.className = 'day-marker';
    el.dataset.day = day;
    el.textContent = day;
    return el;
  }

  function buildSessionMarker(session, day) {
    const el = document.createElement('div');
    el.className = 'session-marker';
    el.dataset.day = day;
    el.dataset.session = String(session);
    el.textContent = `Session ${session}`;
    return el;
  }

  // Last visible (session, day) outside the history fold. Skips the
  // <details> child so freshly-appended messages don't re-emit a marker.
  function lastVisibleState(box) {
    for (let i = box.children.length - 1; i >= 0; i--) {
      const child = box.children[i];
      if (child.tagName === 'DETAILS') continue;
      const day = child.dataset?.day;
      if (!day) continue;
      const sessRaw = child.dataset?.session;
      return {
        day,
        session: sessRaw === undefined || sessRaw === '' ? null : Number(sessRaw),
      };
    }
    return { day: null, session: null };
  }

  // Group messages by session when session info is present; fall back to
  // day-only grouping for messages with no `session`. A session change emits
  // a `.session-marker`; within a session, a day change emits a subtle
  // `.day-marker`.
  function appendMessagesInto(target, messages, state) {
    for (const m of messages) {
      const d = dayKey(m.timestamp);
      const sess = m.session == null ? null : Number(m.session);
      if (sess !== state.session) {
        if (sess != null) target.appendChild(buildSessionMarker(sess, d));
        else if (d !== state.day) target.appendChild(buildDayMarker(d));
        state.session = sess;
        state.day = d;
      } else if (d !== state.day) {
        target.appendChild(buildDayMarker(d));
        state.day = d;
      }
      target.appendChild(buildMessageRow(m));
    }
  }

  // Render the entire thread: older messages go inside a collapsible
  // `<details class="thread-history">` fold, latest PAGE_SIZE stay visible
  // below. Matches the TODO / Completed Chambers fold pattern so the
  // dashboard stays consistent.
  function renderThreadContents() {
    const box = view.msgBox;
    box.innerHTML = '';
    view.historyEl = null;
    view.historyCount = 0;
    const messages = view.allMessages;
    const total = messages.length;
    const historyCount = Math.max(0, total - PAGE_SIZE);
    if (historyCount > 0) {
      const details = document.createElement('details');
      details.className = 'thread-history';
      const summary = document.createElement('summary');
      summary.textContent = `History (${historyCount} older)`;
      details.appendChild(summary);
      const body = document.createElement('div');
      body.className = 'thread-history-body';
      const state = { session: null, day: null };
      appendMessagesInto(body, messages.slice(0, historyCount), state);
      details.appendChild(body);
      box.appendChild(details);
      view.historyEl = details;
      view.historyCount = historyCount;
    }
    const state = { session: null, day: null };
    appendMessagesInto(box, messages.slice(historyCount), state);
    view.lastMsgKey = total > 0 ? messageKey(messages[total - 1]) : null;
  }

  function buildThread(pane, messages) {
    const box = document.createElement('div');
    box.className = 'thread';
    pane.appendChild(box);
    view.msgBox = box;
    view.allMessages = messages || [];
    renderThreadContents();
    // Scroll to bottom after layout settles (deep-link + font load timing).
    requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
  }

  function isAtBottom(el) {
    return (el.scrollHeight - el.scrollTop - el.clientHeight) < 40;
  }

  function updateMessages(messages) {
    if (!view.msgBox) return;
    const box = view.msgBox;
    const wasAtBottom = isAtBottom(box);
    view.allMessages = messages;
    let startAt = 0;
    if (view.lastMsgKey) {
      const idx = messages.findIndex(m => messageKey(m) === view.lastMsgKey);
      if (idx < 0) {
        // Lost our marker — full rerender.
        renderThreadContents();
        box.scrollTop = box.scrollHeight;
        return;
      }
      startAt = idx + 1;
    }
    if (startAt < messages.length) {
      // New messages append outside the fold so the thread keeps growing
      // downward. The fold boundary is frozen at build time — we don't move
      // already-visible messages back into history just because the list
      // grew past PAGE_SIZE.
      const state = lastVisibleState(box);
      appendMessagesInto(box, messages.slice(startAt), state);
      view.lastMsgKey = messageKey(messages[messages.length - 1]);
    }
    if (wasAtBottom) box.scrollTop = box.scrollHeight;
  }

  function buildCompose(pane, id) {
    const wrap = document.createElement('div');
    wrap.className = 'compose';
    wrap.innerHTML = `
      <div class="compose-inner">
        <textarea id="send-body" rows="1" placeholder="Send a message to the chamber… (Enter for newline, ⌘↵ to send)"></textarea>
        <div class="controls">
          <div class="hint">⌘↵</div>
          <button class="send">Send</button>
        </div>
      </div>`;
    pane.appendChild(wrap);
    const ta   = wrap.querySelector('#send-body');
    const send = wrap.querySelector('.send');
    // Auto-grow with content so multi-line drafts are visible up to the CSS
    // `max-height` cap (after which the textarea scrolls). Without this, the
    // `rows="1"` control hides all but the last line of a long message.
    const autoGrow = () => {
      ta.style.height = 'auto';
      ta.style.height = ta.scrollHeight + 'px';
    };
    ta.addEventListener('input', autoGrow);
    async function submit() {
      const body = ta.value.trim();
      if (!body) return;
      try {
        await fetchJSON(`/api/chambers/${id}/send`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ body }),
        });
        ta.value = '';
        autoGrow();
      } catch (e) { toast(e.message, 'error'); }
    }
    send.addEventListener('click', submit);
    ta.addEventListener('keydown', e => {
      // Enter inserts a newline (default textarea behaviour). ⌘/Ctrl+Enter
      // submits — this matches the keymap hint rendered on the button.
      if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
    });
  }

  function activateDrawerTab(name) {
    const tabs = document.querySelectorAll('.drawer-tab');
    const panels = document.querySelectorAll('.drawer-body');
    let matched = false;
    tabs.forEach(t => {
      const isMatch = t.getAttribute('data-tab') === name;
      t.classList.toggle('active', isMatch);
      if (isMatch) matched = true;
    });
    if (!matched) return;
    panels.forEach(p => {
      p.classList.toggle('hidden', p.getAttribute('data-panel') !== name);
    });
    if (name === 'log' && view && view.logEl) {
      view.logEl.scrollTop = view.logEl.scrollHeight;
    }
  }

  function initDrawerTabs() {
    document.querySelectorAll('.drawer-tab').forEach(tab => {
      tab.addEventListener('click', () => activateDrawerTab(tab.getAttribute('data-tab')));
    });
    // ?drawer=todos|sync|settings|log opens that drawer panel on load.
    // Useful for direct links and scripted screenshots.
    const initial = new URLSearchParams(window.location.search).get('drawer');
    if (initial) activateDrawerTab(initial);
  }

  const TODO_PENDING_CAP = 20;

  function todoSignature(todos) {
    if (!todos || !todos.length) return '0:';
    return todos.length + ':' + todos.map(t => `${t.id}|${t.done ? 1 : 0}|${t.at || ''}`).join(',');
  }

  function buildTodoRow(t) {
    const row = document.createElement('div');
    row.className = 'todo' + (t.done ? ' done' : '');
    const check = document.createElement('span');
    check.className = 'check';
    const text = document.createElement('span');
    text.className = 'text';
    text.textContent = t.text;
    row.appendChild(check);
    row.appendChild(text);
    if (t.at) {
      const when = document.createElement('span');
      when.className = 'when';
      applyWake(when, t.at);
      row.appendChild(when);
    }
    return row;
  }

  function renderTodos(todos) {
    const sig = todoSignature(todos);
    if (sig === view.lastTodoSig) return;
    view.lastTodoSig = sig;
    const all = todos || [];
    // Partition + sort: pending by `at` ascending (undated last), done by id
    // descending (most recently added first).
    const pending = all.filter(t => !t.done).slice().sort((a, b) => {
      if (a.at && b.at) return a.at < b.at ? -1 : a.at > b.at ? 1 : 0;
      if (a.at) return -1;
      if (b.at) return 1;
      return a.id - b.id;
    });
    const done = all.filter(t => t.done).slice().sort((a, b) => b.id - a.id);

    const box = document.getElementById('panel-todos');
    box.innerHTML = '';
    const title = document.createElement('div');
    title.className = 'section-title';
    title.innerHTML =
      `Todos <span class="count">${pending.length} pending · ${done.length} done</span>`;
    box.appendChild(title);

    const list = document.createElement('div');
    list.className = 'todos';
    const visible = pending.slice(0, TODO_PENDING_CAP);
    for (const t of visible) list.appendChild(buildTodoRow(t));
    if (pending.length > TODO_PENDING_CAP) {
      const more = document.createElement('div');
      more.className = 'todo-more';
      more.textContent = `+${pending.length - TODO_PENDING_CAP} more pending`;
      list.appendChild(more);
    }
    box.appendChild(list);

    if (done.length) {
      const details = document.createElement('details');
      details.className = 'todo-history';
      const summary = document.createElement('summary');
      summary.textContent = `History (${done.length})`;
      details.appendChild(summary);
      const histList = document.createElement('div');
      histList.className = 'todos';
      for (const t of done) histList.appendChild(buildTodoRow(t));
      details.appendChild(histList);
      box.appendChild(details);
    }

    document.getElementById('todos-count').textContent = pending.length;
  }

  function syncSignature(sync) {
    if (!sync || !sync.length) return '0:';
    return sync.length + ':' + sync.map(s =>
      `${s.backend}|${s.running}|${s.installed}|${s.target}|${s.last_pushed_session ?? ''}`
    ).join(',');
  }

  async function syncToggle(id, backend, running) {
    if (running) {
      if (!window.confirm(`Stop ${backend} sync and uninstall its service?`)) return false;
    }
    try {
      const resp = await fetchJSON(
        `/api/chambers/${id}/sync/${backend}/${running ? 'stop' : 'start'}`,
        { method: 'POST' }
      );
      toast(resp.message || (running ? 'stopped' : 'started'), resp.ok ? '' : 'error');
      return true;
    } catch (e) {
      toast(e.message, 'error');
      return false;
    }
  }

  function renderSync(id, sync) {
    const sig = syncSignature(sync);
    if (sig === view.lastSyncSig) return;
    view.lastSyncSig = sig;
    const box = view.syncEl;
    if (!box) return;
    box.innerHTML = '';
    const items = sync || [];
    const title = document.createElement('div');
    title.className = 'section-title';
    const running = items.filter(s => s.running).length;
    title.innerHTML = `Message sync <span class="count">${running}/${items.length}</span>`;
    box.appendChild(title);
    if (!items.length) {
      const empty = document.createElement('div');
      empty.className = 'sync-help';
      empty.textContent = 'No message sync is configured for this chamber.';
      box.appendChild(empty);
      return;
    }
    const list = document.createElement('div');
    list.className = 'sync-list';

    for (const s of items) {
      const card = document.createElement('div');
      card.className = 'sync-card';
      card.innerHTML = `
        <div class="sync-card-head">
          <div class="sync-card-name">
            ${escapeHtml(s.backend)}
            <span class="badge${s.running ? '' : ' stopped'}">${s.running ? 'running' : 'off'}</span>
          </div>
          <label class="sync-toggle">
            <input type="checkbox" ${s.running ? 'checked' : ''}>
            <span class="slider"></span>
          </label>
        </div>
        <div class="sync-target">${escapeHtml(s.target || '—')}</div>
        <div class="sync-foot">
          <span>${s.last_pushed_session != null
            ? 'last push · session #' + s.last_pushed_session
            : (s.configured ? 'configured' : '')}</span>
        </div>`;
      const cb = card.querySelector('input[type=checkbox]');
      cb.addEventListener('change', async () => {
        // Keep the user's click visible while the request is in flight — the
        // SSE `status` event re-renders from truth when the backend catches up.
        // Revert only if the request fails or the confirm is cancelled.
        const prev = s.running;
        const ok = await syncToggle(id, s.backend, prev);
        if (!ok) cb.checked = prev;
      });
      list.appendChild(card);
    }
    box.appendChild(list);
  }

  function renderLog(status) {
    const box = document.getElementById('panel-log');
    box.innerHTML = '';
    const title = document.createElement('div');
    title.className = 'section-title';
    title.textContent = 'cryo.log';
    box.appendChild(title);
    const wrap = document.createElement('div');
    wrap.className = 'log-wrap';
    const log = document.createElement('pre');
    log.className = 'log';
    log.id = 'log';
    log.textContent = status.log_tail || '';
    wrap.appendChild(log);
    box.appendChild(wrap);
    view.logEl = log;
    log.scrollTop = log.scrollHeight;
  }

  // Center-pane tab strip: Messages / Plan / Notes. Tab bodies are siblings
  // hidden via `.hidden`; we never tear down a body when switching tabs so
  // each panel's scrollTop and textarea drafts survive tab changes.
  function centerTabStorageKey(chamberId) { return `hub:tab:${chamberId}`; }

  function buildCenterTabs(pane, chamberId, entry) {
    const bar = document.createElement('div');
    bar.className = 'center-tabs';

    const messagesTab = document.createElement('div');
    messagesTab.className = 'center-tab';
    messagesTab.dataset.tab = 'messages';
    messagesTab.textContent = 'Messages';
    const unread = document.createElement('span');
    unread.className = 'tab-count';
    unread.hidden = true;
    messagesTab.appendChild(unread);
    view.messagesUnreadEl = unread;

    const planTab = document.createElement('div');
    planTab.className = 'center-tab';
    planTab.dataset.tab = 'plan';
    planTab.textContent = 'Plan';

    const notesTab = document.createElement('div');
    notesTab.className = 'center-tab';
    notesTab.dataset.tab = 'notes';
    notesTab.textContent = "Agent's Notes";

    bar.appendChild(messagesTab);
    bar.appendChild(planTab);
    bar.appendChild(notesTab);
    pane.appendChild(bar);

    const messagesBody = document.createElement('div');
    messagesBody.className = 'center-tab-body messages-body';
    messagesBody.dataset.panel = 'messages';
    pane.appendChild(messagesBody);

    const planBody = document.createElement('div');
    planBody.className = 'center-tab-body file-body-panel hidden';
    planBody.dataset.panel = 'plan';
    pane.appendChild(planBody);

    const notesBody = document.createElement('div');
    notesBody.className = 'center-tab-body file-body-panel hidden';
    notesBody.dataset.panel = 'notes';
    pane.appendChild(notesBody);

    view.messagesBodyEl = messagesBody;
    view.planBodyEl = planBody;
    view.notesBodyEl = notesBody;

    const tabs = [messagesTab, planTab, notesTab];
    const bodies = [messagesBody, planBody, notesBody];
    function activate(name) {
      tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === name));
      bodies.forEach(b => b.classList.toggle('hidden', b.dataset.panel !== name));
      try { localStorage.setItem(centerTabStorageKey(chamberId), name); } catch (e) {}
    }
    tabs.forEach(t => t.addEventListener('click', () => activate(t.dataset.tab)));

    let initial = 'messages';
    // ?tab=plan|notes wins for shareable / scriptable links; falls back to
    // the per-chamber localStorage value, then to messages.
    const queryTab = new URLSearchParams(window.location.search).get('tab');
    if (queryTab && tabs.some(t => t.dataset.tab === queryTab)) {
      initial = queryTab;
    } else {
      try {
        const saved = localStorage.getItem(centerTabStorageKey(chamberId));
        if (saved && tabs.some(t => t.dataset.tab === saved)) initial = saved;
      } catch (e) {}
    }
    activate(initial);

    updateMessagesUnreadBadge(entry);
  }

  function updateMessagesUnreadBadge(entry) {
    if (!view || !view.messagesUnreadEl) return;
    const n = entry && entry.unread ? entry.unread : 0;
    view.messagesUnreadEl.textContent = n > 0 ? n : '';
    view.messagesUnreadEl.hidden = n === 0;
  }

  function buildPlanBody(box) {
    box.innerHTML = '';
    const article = document.createElement('article');
    article.className = 'plan-md';
    box.appendChild(article);
    view.planEl = article;
    view.planLastHtml = null;
  }

  function buildNotesBody(box) {
    box.innerHTML = '';
    const article = document.createElement('article');
    article.className = 'plan-md';
    box.appendChild(article);
    view.notesEl = article;
    view.notesLastHtml = null;
  }

  function renderNotes(status) {
    if (!view || !view.notesEl) return;
    setRenderedMarkdown(
      view.notesEl,
      status.notes_html || '',
      'notesLastHtml',
      "No NOTES.md in this chamber.",
      { stickToBottom: true }
    );
  }

  function renderPlan(status) {
    if (!view || !view.planEl) return;
    setRenderedMarkdown(
      view.planEl,
      status.plan_html || '',
      'planLastHtml',
      'No plan.md in this chamber.'
    );
  }

  // Shared renderer for tab bodies whose content is server-rendered HTML
  // (currently Plan and Notes). Uses innerHTML directly because the source
  // markdown was rendered through `render_markdown_safe`, which already
  // escapes raw HTML and drops image tags.
  //
  // `opts.stickToBottom` makes the renderer "follow the tail": on first
  // render and on any update where the operator was already scrolled to the
  // bottom, the scroller jumps to the new bottom. Otherwise scrollTop is
  // preserved so the operator's reading position survives SSE refreshes.
  // The empty-on-init case naturally counts as "at bottom" so the first
  // non-empty render lands at the bottom.
  function setRenderedMarkdown(el, html, lastKey, emptyLabel, opts) {
    const stickToBottom = !!(opts && opts.stickToBottom);
    if (html === '') {
      if (!el.classList.contains('empty') || view[lastKey] !== '') {
        el.classList.add('empty');
        el.textContent = emptyLabel;
        view[lastKey] = '';
      }
      return;
    }
    if (view[lastKey] === html && !el.classList.contains('empty')) return;
    const scroller = el.parentNode;
    let prev = 0;
    let wasAtBottom = false;
    if (scroller) {
      prev = scroller.scrollTop;
      wasAtBottom = (scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight) < 30;
    }
    el.classList.remove('empty');
    el.innerHTML = html;
    view[lastKey] = html;
    if (scroller) {
      if (stickToBottom && wasAtBottom) {
        scroller.scrollTop = scroller.scrollHeight;
      } else {
        scroller.scrollTop = prev;
      }
    }
  }

  function settingsSignature(rows, raw) {
    if (!rows || rows.length === 0) return raw === '' ? 'empty' : 'raw:' + raw;
    return 'rows:' + rows.map(r => `${r.kind}|${r.key}|${r.value}`).join('\n');
  }

  function renderSettings(status) {
    const box = document.getElementById('panel-settings');
    if (!box) return;
    const rows = Array.isArray(status.settings_rows) ? status.settings_rows : [];
    const raw = status.config_content || '';
    const sig = settingsSignature(rows, raw);
    if (sig === view.settingsLastSig) return;
    view.settingsLastSig = sig;

    box.innerHTML = '';
    const title = document.createElement('div');
    title.className = 'section-title';
    title.textContent = 'cryo.toml';
    box.appendChild(title);

    if (raw === '') {
      const empty = document.createElement('div');
      empty.className = 'settings-empty';
      empty.textContent = 'No cryo.toml in this chamber.';
      box.appendChild(empty);
      view.settingsEl = empty;
      return;
    }

    if (rows.length === 0) {
      // Couldn't parse — fall back to the raw text so the user can still
      // see what's there and fix it.
      const note = document.createElement('div');
      note.className = 'settings-empty';
      note.textContent = '(could not parse — showing raw text)';
      box.appendChild(note);
      const pre = document.createElement('pre');
      pre.className = 'settings-pre';
      pre.textContent = raw;
      box.appendChild(pre);
      view.settingsEl = pre;
      return;
    }

    const list = document.createElement('div');
    list.className = 'settings-list';
    for (const row of rows) {
      const item = document.createElement('div');
      item.className = 'settings-row' + (row.kind === 'section' ? ' section' : '');
      const key = document.createElement('span');
      key.className = 'k';
      key.textContent = row.key;
      const val = document.createElement('span');
      val.className = 'v';
      val.textContent = row.value;
      item.appendChild(key);
      item.appendChild(val);
      list.appendChild(item);
    }
    box.appendChild(list);
    view.settingsEl = list;
  }

  function appendLogLine(line) {
    if (!view || !view.logEl) return;
    const el = view.logEl;
    const atBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) < 30;
    el.textContent += (el.textContent ? '\n' : '') + line;
    if (atBottom) el.scrollTop = el.scrollHeight;
  }

  async function buildDetail(id, seq) {
    const entry = state.chambers.find(c => c.id === id);
    if (!entry) return;
    const pane = document.getElementById('pane');
    const [status, messages, todos, sync] = await Promise.all([
      fetchJSON(`/api/chambers/${id}/status`),
      fetchJSON(`/api/chambers/${id}/messages`),
      fetchJSON(`/api/chambers/${id}/todos`),
      fetchJSON(`/api/chambers/${id}/sync`),
    ]);
    if (seq !== state.detailSeq || state.currentId !== id) return;
    pane.innerHTML = '';
    view = newView();
    view.chamberId = id;
    buildHead(pane, entry, status);
    buildCenterTabs(pane, id, entry);
    buildThread(view.messagesBodyEl, messages);
    buildCompose(view.messagesBodyEl, id);
    buildPlanBody(view.planBodyEl);
    buildNotesBody(view.notesBodyEl);
    view.syncEl = document.getElementById('panel-sync');
    renderTodos(todos);
    renderSync(id, sync);
    renderLog(status);
    renderNotes(status);
    renderPlan(status);
    renderSettings(status);
    document.getElementById('drawer').hidden = false;
  }

  async function updateDetail(id, parts) {
    if (!view || view.chamberId !== id) return;
    const fetches = {};
    if (parts.status)   fetches.status   = fetchJSON(`/api/chambers/${id}/status`);
    if (parts.messages) fetches.messages = fetchJSON(`/api/chambers/${id}/messages`);
    if (parts.todos)    fetches.todos    = fetchJSON(`/api/chambers/${id}/todos`);
    if (parts.sync)     fetches.sync     = fetchJSON(`/api/chambers/${id}/sync`);
    const entries = await Promise.allSettled(
      Object.entries(fetches).map(async ([k, p]) => [k, await p])
    );
    const data = {};
    for (const r of entries) {
      if (r.status === 'fulfilled') data[r.value[0]] = r.value[1];
    }
    const entry = state.chambers.find(c => c.id === id);
    if (entry && data.status) {
      updateHead(entry, data.status);
      updateLifecycleButtons(entry);
      renderLog(data.status); // refreshes log_tail baseline
      renderNotes(data.status);
      renderPlan(data.status);
      renderSettings(data.status);
      updateMessagesUnreadBadge(entry);
    }
    if (data.sync)     renderSync(id, data.sync);
    if (data.todos)    renderTodos(data.todos);
    if (data.messages) updateMessages(data.messages);
  }

  async function selectChamber(id, options = {}) {
    state.currentId = id;
    const seq = ++state.detailSeq;
    const push = options.push !== false;
    if (push && window.location.pathname !== `/c/${id}`) {
      window.history.pushState({}, '', `/c/${id}`);
    }
    renderRail();
    await buildDetail(id, seq);
  }

  function clearSelection() {
    state.currentId = null;
    ++state.detailSeq;
    view = null;
    document.getElementById('pane').innerHTML =
      '<div class="pane-empty"><div>Pick a chamber from the rail</div></div>';
    document.getElementById('drawer').hidden = true;
    renderRail();
  }

  async function syncRouteSelection() {
    const urlId = urlChamberId();
    if (urlId && state.chambers.some(c => c.id === urlId)) {
      await selectChamber(urlId, { push: false });
    } else {
      clearSelection();
    }
  }

  window.addEventListener('popstate', syncRouteSelection);

  document.getElementById('refresh').addEventListener('click', async () => {
    await refreshChamberIndex();
  });

  initDrawerTabs();

  const evt = new EventSource('/api/events');
  evt.addEventListener('message', async e => {
    const d = JSON.parse(e.data);
    if (state.currentId === d.chamber_id) {
      await updateDetail(state.currentId, { messages: true });
    }
    await loadChambers();
  });
  evt.addEventListener('status', async e => {
    const d = JSON.parse(e.data);
    await loadChambers();
    if (state.currentId === d.chamber_id) {
      await updateDetail(state.currentId, { status: true, todos: true, sync: true });
    }
  });
  evt.addEventListener('log', e => {
    const d = JSON.parse(e.data);
    if (state.currentId === d.chamber_id) appendLogLine(d.line);
    scheduleRailRefresh(d.chamber_id);
  });
  evt.addEventListener('index', async () => { await loadChambers(); });

  // Server returns absolute datetimes; tick once a wall-clock minute (with a
  // little extra so we don't drift past the boundary) to repaint relative
  // labels in place. SSE-driven re-renders also call `applyWake`, so the
  // ticker only matters when nothing is changing on the daemon.
  setInterval(refreshWakeRelatives, 30_000);
  setInterval(() => { refreshChamberIndex({ silent: true }); }, 15_000);

  const MODELS_DEV_API_URL = 'https://models.dev/api.json';

  (function () {
    const btnOpen = document.getElementById('chamber-new');
    const backdrop = document.getElementById('modal-backdrop');
    const input = document.getElementById('modal-name-input');
    const providerDetails = document.getElementById('modal-provider-details');
    const providerInput = document.getElementById('modal-provider-input');
    const providerOptions = document.getElementById('modal-provider-options');
    const modelInput = document.getElementById('modal-model-input');
    const modelOptions = document.getElementById('modal-model-options');
    const modelStatus = document.getElementById('modal-model-status');
    const apiKey = document.getElementById('modal-api-key-input');
    const errBox = document.getElementById('modal-error');
    const btnCancel = document.getElementById('modal-cancel');
    const btnCreate = document.getElementById('modal-create');
    let modelCatalog = [];
    let modelCatalogLoaded = false;
    let modelCatalogLoading = false;

    function normalizeCatalogProvider(raw) {
      const id = String(raw && raw.id || '').trim().toLowerCase();
      if (!id) return null;
      const models = Array.isArray(raw.models) ? raw.models
        .map(m => ({
          id: String(m && m.id || '').trim(),
          name: String(m && m.name || m && m.id || '').trim(),
        }))
        .filter(m => m.id) : [];
      return {
        id,
        name: String(raw.name || id).trim(),
        models,
      };
    }

    function providersFromModelsDev(data) {
      const entries = Array.isArray(data)
        ? data.map(p => [p && (p.id || p.provider), p])
        : Object.entries(data || {});
      return entries.map(([id, providerData]) => {
        const modelsValue = providerData && providerData.models;
        const modelEntries = Array.isArray(modelsValue)
          ? modelsValue.map(m => [m && (m.id || m.model), m])
          : Object.entries(modelsValue || {});
        return normalizeCatalogProvider({
          id,
          name: providerData && providerData.name,
          models: modelEntries.map(([modelId, modelData]) => ({
            id: modelId,
            name: modelData && modelData.name,
          })),
        });
      }).filter(Boolean);
    }

    function mergeCatalog(base, incoming) {
      const providers = new Map();
      const addProvider = item => {
        const p = normalizeCatalogProvider(item);
        if (!p) return;
        const existing = providers.get(p.id);
        if (!existing) {
          providers.set(p.id, { ...p, models: [...p.models] });
          return;
        }
        existing.name = existing.name || p.name;
        const modelIds = new Set(existing.models.map(m => m.id));
        for (const m of p.models) {
          if (!modelIds.has(m.id)) {
            existing.models.push(m);
            modelIds.add(m.id);
          }
        }
      };
      base.forEach(addProvider);
      incoming.forEach(addProvider);
      const preferred = new Map(base.map((p, i) => [p.id, i]));
      return Array.from(providers.values()).sort((a, b) => {
        const ai = preferred.has(a.id) ? preferred.get(a.id) : 999;
        const bi = preferred.has(b.id) ? preferred.get(b.id) : 999;
        if (ai !== bi) return ai - bi;
        return a.name.localeCompare(b.name);
      });
    }

    function renderProviderOptions() {
      providerOptions.innerHTML = '';
      for (const p of modelCatalog) {
        const option = document.createElement('option');
        option.value = p.id;
        option.label = p.name;
        providerOptions.appendChild(option);
      }
    }

    function selectedProviderId() {
      return providerInput.value.trim();
    }

    function renderModelOptions() {
      const providerId = selectedProviderId().toLowerCase();
      const found = modelCatalog.find(p => p.id === providerId);
      modelOptions.innerHTML = '';
      for (const m of (found && found.models || [])) {
        const option = document.createElement('option');
        option.value = m.id;
        if (m.name && m.name !== m.id) option.label = m.name;
        modelOptions.appendChild(option);
      }
    }

    async function loadModelsDevCatalog() {
      if (modelCatalogLoaded || modelCatalogLoading) return;
      modelCatalogLoading = true;
      modelStatus.textContent = 'Loading models...';
      try {
        const res = await fetch(MODELS_DEV_API_URL, { cache: 'force-cache' });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        modelCatalog = mergeCatalog([], providersFromModelsDev(data));
        modelCatalogLoaded = true;
        modelStatus.textContent = '';
        renderProviderOptions();
        renderModelOptions();
      } catch (e) {
        modelStatus.textContent = 'Model list unavailable';
      } finally {
        modelCatalogLoading = false;
      }
    }

    function showModal() {
      errBox.hidden = true;
      errBox.textContent = '';
      input.value = '';
      providerDetails.open = false;
      providerInput.value = '';
      modelInput.value = '';
      apiKey.value = '';
      modelStatus.textContent = '';
      renderProviderOptions();
      renderModelOptions();
      backdrop.hidden = false;
      setTimeout(() => input.focus(), 0);
    }

    function hideModal() {
      backdrop.hidden = true;
    }

    function showErr(msg) {
      errBox.textContent = msg;
      errBox.hidden = false;
    }

    btnOpen.addEventListener('click', showModal);
    btnCancel.addEventListener('click', hideModal);
    backdrop.addEventListener('click', e => {
      if (e.target === backdrop) hideModal();
    });
    document.addEventListener('keydown', e => {
      if (!backdrop.hidden && e.key === 'Escape') hideModal();
    });
    input.addEventListener('keydown', e => {
      if (e.key === 'Enter') btnCreate.click();
    });
    providerDetails.addEventListener('toggle', () => {
      if (providerDetails.open) loadModelsDevCatalog();
    });
    providerInput.addEventListener('input', renderModelOptions);
    providerInput.addEventListener('change', renderModelOptions);
    providerInput.addEventListener('keydown', e => {
      if (e.key === 'Enter') btnCreate.click();
    });
    modelInput.addEventListener('keydown', e => {
      if (e.key === 'Enter') btnCreate.click();
    });
    apiKey.addEventListener('keydown', e => {
      if (e.key === 'Enter') btnCreate.click();
    });

    btnCreate.addEventListener('click', async () => {
      const name = input.value.trim();
      const key = apiKey.value.trim();
      const providerId = selectedProviderId();
      const selectedModel = modelInput.value.trim();
      const configureProvider = providerDetails.open
        || providerInput.value.trim()
        || key
        || selectedModel;
      if (!name) {
        showErr('name is empty');
        return;
      }
      if (configureProvider && !providerId) {
        showErr('api key provider is empty');
        return;
      }
      if (configureProvider && !key) {
        showErr('api key is empty');
        return;
      }

      const payload = { name };
      if (configureProvider) {
        payload.api_key_provider = providerId;
        payload.api_key = key;
        if (selectedModel) payload.model = selectedModel;
      }

      btnCreate.disabled = true;
      try {
        const r = await fetch('/api/chambers/new', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload),
        });
        const body = await r.json().catch(() => ({}));
        if (r.status === 201) {
          hideModal();
          await loadChambers();
          if (body.id) {
            await selectChamber(body.id);
          }
        } else {
          showErr((body && body.error) || `error: HTTP ${r.status}`);
        }
      } catch (e) {
        showErr(`error: ${e}`);
      } finally {
        btnCreate.disabled = false;
      }
    });
  })();

  // Expose for subsequent task wiring.
  window.__hub = { state, fetchJSON, toast, escapeHtml, hash32, renderRail, loadChambers, selectChamber };

  (async () => {
    await loadChambers();
    await syncRouteSelection();
  })();
})();
</script>
</body>
</html>
