Ryanhub - file viewer
filename: assistant/server/static/app.js
branch: main
back to repo
(function () {
  const $ = (id) => document.getElementById(id);

  const els = {
    statusPill: $("status-pill"),
    statusText: $("status-text"),
    modelLine: $("model-line"),
    chatLog: $("chat-log"),
    form: $("chat-form"),
    prompt: $("prompt"),
    send: $("send-btn"),
    cancel: $("cancel-btn"),
    widgets: $("widgets"),
    activityLog: $("activity-log"),
    summary: $("summary"),
    calGrid: $("cal-grid"),
    calMonthLabel: $("cal-month-label"),
    calPrev: $("cal-prev"),
    calNext: $("cal-next"),
    calToday: $("cal-today"),
    calSelectedLabel: $("cal-selected-label"),
    calDayItems: $("cal-day-items"),
    calQuickForm: $("cal-quick-form"),
    calQuickTitle: $("cal-quick-title"),
    calQuickWhen: $("cal-quick-when"),
    calPendingList: $("cal-pending-list"),
    calPendingWrap: $("cal-pending-wrap"),
    scratchpadPanel: $("scratchpad-panel"),
    scratchpadEditor: $("scratchpad-editor"),
    scratchpadRendered: $("scratchpad-rendered"),
    scratchpadRenderToggle: $("scratchpad-render-toggle"),
    scratchpadHint: $("scratchpad-hint"),
  };

  const SCRATCHPAD_RENDER_KEY = "assistant-scratchpad-render-v2";
  let scratchpadRenderPreview = (() => {
    try {
      const v = sessionStorage.getItem(SCRATCHPAD_RENDER_KEY);
      if (v === null) return true;
      return v === "1";
    } catch {
      return true;
    }
  })();

  let scratchpadLoadedOnce = false;
  let scratchpadSaveTimer = null;
  let scratchpadPollTimer = null;

  const UI_STORAGE_KEY = "assistant-ui-v1";
  const chatHistory = [];
  const activityHistory = [];
  let summaryMarkdown = "";
  let uiRestoring = false;
  let persistTimer = null;

  const pad2 = (n) => String(n).padStart(2, "0");

  const calState = {
    viewYear: new Date().getFullYear(),
    viewMonth: new Date().getMonth(),
    selectedKey: "",
    items: [],
    pending: [],
    lastRevision: -1,
    loading: false,
  };

  function isoDateLocal(d) {
    return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
  }

  function monthRangeUTC(y, m0) {
    const from = `${y}-${pad2(m0 + 1)}-01`;
    const next = new Date(Date.UTC(y, m0 + 1, 1));
    const to = `${next.getUTCFullYear()}-${pad2(next.getUTCMonth() + 1)}-01`;
    return { from, to };
  }

  function addDaysToKey(dayKey, days) {
    if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dayKey || ""))) return "";
    const parts = String(dayKey).split("-").map((x) => parseInt(x, 10));
    if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return "";
    const d = new Date(parts[0], parts[1] - 1, parts[2]);
    d.setDate(d.getDate() + days);
    return isoDateLocal(d);
  }

  function visibleGridRange(y, m) {
    const first = new Date(y, m, 1);
    const startWeekday = first.getDay();
    const start = new Date(y, m, 1 - startWeekday);
    const endExclusive = new Date(start);
    endExclusive.setDate(start.getDate() + 42);
    return {
      from: isoDateLocal(start),
      to: isoDateLocal(endExclusive),
    };
  }

  function windowForCalendarFetch() {
    const monthWin = visibleGridRange(calState.viewYear, calState.viewMonth);
    const selectedStart = /^\d{4}-\d{2}-\d{2}$/.test(String(calState.selectedKey || ""))
      ? calState.selectedKey
      : isoDateLocal(new Date());
    const selectedEnd = addDaysToKey(selectedStart, 14) || selectedStart;
    const from = monthWin.from < selectedStart ? monthWin.from : selectedStart;
    const to = monthWin.to > selectedEnd ? monthWin.to : selectedEnd;
    return { from, to };
  }

  function primaryDayKey(it) {
    const s = it.due_at || "";
    if (!s) return "";
    const m = String(s).match(/^(\d{4}-\d{2}-\d{2})/);
    if (m) return m[1];
    const d = new Date(s);
    if (isNaN(d.getTime())) return "";
    return isoDateLocal(d);
  }

  function itemOnDay(it, dayKey) {
    const k = primaryDayKey(it);
    return k === dayKey;
  }

  const CAL_WHEN_UNDATED = "undated";
  /** How many days ahead (including today) appear in the when dropdown. */
  const WHEN_ROLLING_DAYS = 45;

  function isUndated(it) {
    const d = String(it.due_at || "").trim();
    return !d;
  }

  function countForDay(dayKey) {
    return calState.items.filter((it) => itemOnDay(it, dayKey)).length;
  }

  async function loadCalendarFromServer() {
    if (calState.loading) return;
    calState.loading = true;
    try {
      const { from, to } = windowForCalendarFetch();
      const [itemsRes, pendRes] = await Promise.all([
        fetch(`/api/calendar/items?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { cache: "no-store" }),
        fetch("/api/calendar/pending", { cache: "no-store" }),
      ]);
      if (itemsRes.ok) {
        const data = await itemsRes.json();
        calState.items = Array.isArray(data.items) ? data.items : [];
        if (data.revision != null) calState.lastRevision = Number(data.revision);
      }
      if (pendRes.ok) {
        const data = await pendRes.json();
        calState.pending = Array.isArray(data.pending) ? data.pending : [];
        if (data.revision != null) calState.lastRevision = Number(data.revision);
      }
      renderCalendarPanel();
    } catch {
      /* ignore */
    } finally {
      calState.loading = false;
    }
  }

  function renderCalendarPanel() {
    if (!els.calGrid) return;
    const y = calState.viewYear;
    const m = calState.viewMonth;
    els.calMonthLabel.textContent = new Date(y, m, 1).toLocaleString(undefined, {
      month: "long",
      year: "numeric",
    });

    const first = new Date(y, m, 1);
    const startWeekday = first.getDay();
    const daysInMonth = new Date(y, m + 1, 0).getDate();
    const prevMonthDays = new Date(y, m, 0).getDate();

    if (!calState.selectedKey) {
      calState.selectedKey = isoDateLocal(new Date());
    }

    const cells = [];
    for (let i = 0; i < startWeekday; i++) {
      const day = prevMonthDays - startWeekday + i + 1;
      const pm = m === 0 ? 11 : m - 1;
      const py = m === 0 ? y - 1 : y;
      const key = `${py}-${pad2(pm + 1)}-${pad2(day)}`;
      cells.push({ key, label: day, muted: true });
    }
    for (let d = 1; d <= daysInMonth; d++) {
      const key = `${y}-${pad2(m + 1)}-${pad2(d)}`;
      cells.push({ key, label: d, muted: false });
    }
    const trailing = 42 - cells.length;
    for (let i = 1; i <= trailing; i++) {
      const nm = m === 11 ? 0 : m + 1;
      const ny = m === 11 ? y + 1 : y;
      const key = `${ny}-${pad2(nm + 1)}-${pad2(i)}`;
      cells.push({ key, label: i, muted: true });
    }

    els.calGrid.innerHTML = "";
    for (const c of cells) {
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = "cal-cell" + (c.muted ? " muted-day" : "");
      if (c.key === calState.selectedKey) btn.classList.add("selected");
      const n = document.createElement("span");
      n.className = "cal-day-num";
      n.textContent = String(c.label);
      btn.appendChild(n);
      const cnt = countForDay(c.key);
      if (cnt > 0) {
        const dots = document.createElement("span");
        dots.className = "cal-dots";
        dots.textContent = cnt > 3 ? "•••+" : "•".repeat(Math.min(cnt, 3));
        btn.appendChild(dots);
      }
      btn.addEventListener("click", () => {
        calState.selectedKey = c.key;
        renderCalendarPanel();
      });
      els.calGrid.appendChild(btn);
    }

    const sel = calState.selectedKey;
    if (els.calSelectedLabel) {
      const today = new Date();
      const todayText = today.toLocaleDateString(undefined, {
        weekday: "long",
        month: "long",
        day: "numeric",
        year: "numeric",
      });
      els.calSelectedLabel.textContent = `today is ${todayText}.`;
    }
    renderDayDetail(sel);
    renderPending();
    syncQuickFormDefaults();
  }

  /** YYYY-MM-DD for today + next (WHEN_ROLLING_DAYS-1) days; includes extraKey if outside that window. */
  function rollingWhenDateKeys(extraKey) {
    const keys = [];
    const set = new Set();
    const start = new Date();
    start.setHours(0, 0, 0, 0);
    for (let i = 0; i < WHEN_ROLLING_DAYS; i++) {
      const d = new Date(start);
      d.setDate(start.getDate() + i);
      const k = isoDateLocal(d);
      set.add(k);
      keys.push(k);
    }
    const ek = String(extraKey || "").trim();
    if (/^\d{4}-\d{2}-\d{2}$/.test(ek) && !set.has(ek)) {
      keys.push(ek);
      keys.sort();
    }
    return keys;
  }

  function formatDatePickLabel(iso) {
    const parts = String(iso).split("-").map((x) => parseInt(x, 10));
    if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return iso;
    const dt = new Date(parts[0], parts[1] - 1, parts[2]);
    return dt.toLocaleDateString(undefined, {
      weekday: "short",
      month: "short",
      day: "numeric",
      year: "numeric",
    });
  }

  function rebuildCalWhenSelect() {
    const sel = els.calQuickWhen;
    if (!sel) return;
    const prev = sel.value;
    sel.innerHTML = "";
    const oU = document.createElement("option");
    oU.value = CAL_WHEN_UNDATED;
    oU.textContent = "No date";
    sel.appendChild(oU);
    const dates = rollingWhenDateKeys(calState.selectedKey);
    for (const k of dates) {
      const o = document.createElement("option");
      o.value = k;
      o.textContent = formatDatePickLabel(k);
      sel.appendChild(o);
    }
    if (prev === CAL_WHEN_UNDATED) {
      sel.value = CAL_WHEN_UNDATED;
    } else {
      const preferred =
        dates.includes(calState.selectedKey) ? calState.selectedKey : dates.includes(prev) ? prev : dates[0];
      if (preferred) sel.value = preferred;
    }
  }

  function syncQuickFormDefaults() {
    rebuildCalWhenSelect();
  }

  function renderDayDetail(dayKey) {
    function weekdayNameFromKey(key) {
      if (!/^\d{4}-\d{2}-\d{2}$/.test(String(key || ""))) return "";
      const y = parseInt(key.slice(0, 4), 10);
      const m = parseInt(key.slice(5, 7), 10) - 1;
      const d = parseInt(key.slice(8, 10), 10);
      const dt = new Date(y, m, d);
      if (isNaN(dt.getTime())) return "";
      return dt.toLocaleDateString(undefined, { weekday: "long" });
    }

    if (!els.calDayItems) return;
    els.calDayItems.innerHTML = "";
    if (!dayKey) return;
    const undated = calState.items.filter((it) => isUndated(it));

    function appendSection(title, list, emptyText) {
      const head = document.createElement("li");
      head.className = "cal-section-head";
      head.textContent = title;
      els.calDayItems.appendChild(head);
      if (list.length === 0) {
        const li = document.createElement("li");
        li.className = "muted small";
        li.textContent = emptyText;
        els.calDayItems.appendChild(li);
        return;
      }
      for (const it of list) {
        appendCalendarItemRow(it);
      }
    }

    function appendCalendarItemRow(it) {
      const li = document.createElement("li");
      const dueKey = primaryDayKey(it);
      const todayKey = isoDateLocal(new Date());
      const isSelectedDay = !!dueKey && dueKey === dayKey;
      const isToday = !!dueKey && dueKey === todayKey;
      if (isSelectedDay) li.classList.add("cal-item-selected-day");
      else if (isToday) li.classList.add("cal-item-today");
      const t = document.createElement("div");
      t.className = "cal-item-title";
      const pill = document.createElement("span");
      pill.className = "cal-status-pill " + String(it.status || "open");
      pill.textContent = String(it.status || "open");
      const txt = document.createElement("span");
      txt.textContent = it.title || "(untitled)";
      t.appendChild(pill);
      t.appendChild(txt);
      li.appendChild(t);
      const meta = document.createElement("div");
      meta.className = "cal-item-meta";
      const bits = [];
      if (isUndated(it)) bits.push("no date");
      if (it.due_at) {
        const wk = weekdayNameFromKey(primaryDayKey(it));
        bits.push(wk ? `due ${it.due_at}, ${wk}` : `due ${it.due_at}`);
      }
      meta.textContent = bits.join(" · ");
      li.appendChild(meta);
      const actions = document.createElement("div");
      actions.className = "cal-item-actions";
      if (it.status === "open") {
        const b1 = document.createElement("button");
        b1.type = "button";
        b1.textContent = "done";
        b1.addEventListener("click", () => completeItem(it.id));
        actions.appendChild(b1);
      }
      const b2 = document.createElement("button");
      b2.type = "button";
      b2.textContent = "delete";
      b2.addEventListener("click", () => deleteItem(it.id));
      actions.appendChild(b2);
      li.appendChild(actions);
      els.calDayItems.appendChild(li);
    }

    appendSection("undated", undated, "no undated items");

    const selectedLabel = /^\d{4}-\d{2}-\d{2}$/.test(dayKey)
      ? new Date(
          parseInt(dayKey.slice(0, 4), 10),
          parseInt(dayKey.slice(5, 7), 10) - 1,
          parseInt(dayKey.slice(8, 10), 10)
        ).toLocaleDateString(undefined, {
          weekday: "long",
          month: "long",
          day: "numeric",
          year: "numeric",
        })
      : dayKey;
    const rangeEnd = addDaysToKey(dayKey, 14);
    const upcoming = calState.items
      .filter((it) => {
        if (isUndated(it)) return false;
        const k = primaryDayKey(it);
        if (!k) return false;
        return k >= dayKey && (!rangeEnd || k < rangeEnd);
      })
      .sort((a, b) => {
        const ka = primaryDayKey(a);
        const kb = primaryDayKey(b);
        if (ka !== kb) return ka < kb ? -1 : 1;
        return Number(a.id || 0) - Number(b.id || 0);
      });
    appendSection(`selected . ${selectedLabel}`, upcoming, "no items in next two weeks");
  }

  async function completeItem(id) {
    const it = calState.items.find((x) => x.id === id);
    if (!it) return;
    let due_at = String(it.due_at || "").trim();
    if (isUndated(it)) {
      const dk = String(calState.selectedKey || "").trim();
      if (/^\d{4}-\d{2}-\d{2}$/.test(dk)) {
        due_at = dk;
      } else {
        due_at = isoDateLocal(new Date());
      }
    }
    const body = {
      title: it.title,
      notes: it.notes || "",
      status: "done",
      due_at,
    };
    const res = await fetch(`/api/calendar/items/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (res.ok) await loadCalendarFromServer();
  }

  async function deleteItem(id) {
    if (!confirm("Delete this item?")) return;
    const res = await fetch(`/api/calendar/items/${id}`, { method: "DELETE" });
    if (res.ok) await loadCalendarFromServer();
  }

  /** API returns `payload` as a nested object; older paths may send a JSON string. */
  function parsePendingPayload(raw) {
    if (raw == null) return null;
    if (typeof raw === "object") return raw;
    try {
      return JSON.parse(String(raw));
    } catch {
      return null;
    }
  }

  function pendingDueFromItem(item) {
    if (!item || typeof item !== "object") return "";
    const d = String(item.due_at || item.dueAt || "").trim();
    if (!d) return "";
    const m = d.match(/^(\d{4}-\d{2}-\d{2})/);
    return m ? m[1] : "";
  }

  function storeItemByID(id) {
    return calState.items.find((x) => x.id === id) || null;
  }

  /** Load one item by id (not limited to the visible month). */
  async function fetchCalendarItemById(id) {
    const res = await fetch(`/api/calendar/items/${id}`, { cache: "no-store" });
    if (!res.ok) return null;
    const data = await res.json();
    if (data.revision != null) calState.lastRevision = Number(data.revision);
    return data.item || null;
  }

  /** One line for the calendar day (localized, no duplicate ISO). */
  function formatPendingDateDisplay(iso) {
    if (!iso) return "no date";
    return formatDatePickLabel(iso);
  }

  function renderPending() {
    if (!els.calPendingList) return;
    els.calPendingList.innerHTML = "";
    const list = calState.pending || [];
    if (els.calPendingWrap) {
      els.calPendingWrap.style.display = list.length === 0 ? "none" : "block";
    }
    if (list.length === 0) {
      return;
    }
    for (const p of list) {
      const li = document.createElement("li");
      const payload = parsePendingPayload(p.payload);
      const action = payload && payload.action ? String(payload.action) : "?";
      const row = document.createElement("div");
      row.className = "cal-pending-row";

      const body = document.createElement("div");
      body.className = "cal-pending-body";

      const line1 = document.createElement("div");
      line1.className = "cal-pending-line";

      const badge = document.createElement("span");
      badge.className = "cal-pending-badge";
      badge.textContent = `#${p.id}`;

      const actEl = document.createElement("span");
      actEl.className = "cal-pending-action";
      actEl.textContent = action;

      const nameEl = document.createElement("span");
      nameEl.className = "cal-pending-title-text";

      const dateEl = document.createElement("div");
      dateEl.className = "cal-pending-action";

      const item = (payload && payload.item) || {};
      const targetNumId = payload && payload.id ? Number(payload.id) : 0;

      if (action === "create") {
        nameEl.textContent = String(item.title || "").trim() || "(no title)";
        dateEl.textContent = formatPendingDateDisplay(pendingDueFromItem(item));
      } else if (action === "update") {
        nameEl.textContent = String(item.title || "").trim() || "(no title)";
        let iso = pendingDueFromItem(item);
        const fromStore = targetNumId > 0 ? storeItemByID(targetNumId) : null;
        if (fromStore) {
          if (!String(item.title || "").trim()) nameEl.textContent = String(fromStore.title || "").trim() || "(no title)";
          if (!iso) iso = pendingDueFromItem(fromStore);
        }
        dateEl.textContent = formatPendingDateDisplay(iso);
        if (targetNumId > 0 && !fromStore) {
          fetchCalendarItemById(targetNumId).then((it) => {
            if (!nameEl.isConnected) return;
            if (it) {
              if (!String(item.title || "").trim()) nameEl.textContent = String(it.title || "").trim() || "(no title)";
              const iso2 = pendingDueFromItem(item) || pendingDueFromItem(it);
              dateEl.textContent = formatPendingDateDisplay(iso2);
            }
          });
        }
      } else {
        const t = targetNumId > 0 ? storeItemByID(targetNumId) : null;
        if (t) {
          nameEl.textContent = String(t.title || "").trim() || `item #${targetNumId}`;
          dateEl.textContent = formatPendingDateDisplay(pendingDueFromItem(t));
        } else if (targetNumId > 0) {
          nameEl.textContent = String(p.summary || action || `item #${targetNumId}`).trim();
          dateEl.textContent = "…";
          fetchCalendarItemById(targetNumId)
            .then((it) => {
              if (!dateEl.isConnected) return;
              if (it) {
                nameEl.textContent = String(it.title || "").trim() || `item #${targetNumId}`;
                dateEl.textContent = formatPendingDateDisplay(pendingDueFromItem(it));
              } else {
                dateEl.textContent = "no date";
              }
            })
            .catch(() => {
              if (dateEl.isConnected) dateEl.textContent = "-";
            });
        } else {
          nameEl.textContent = String(p.summary || action || "(pending)").trim();
          dateEl.textContent = "-";
        }
      }

      line1.appendChild(badge);
      line1.appendChild(actEl);
      line1.appendChild(nameEl);
      body.appendChild(line1);
      body.appendChild(dateEl);

      const actions = document.createElement("div");
      actions.className = "cal-pending-actions";
      const ok = document.createElement("button");
      ok.type = "button";
      ok.className = "confirm";
      ok.textContent = "confirm";
      ok.addEventListener("click", () => confirmPending(p.id));
      const no = document.createElement("button");
      no.type = "button";
      no.textContent = "reject";
      no.addEventListener("click", () => rejectPending(p.id));
      actions.appendChild(ok);
      actions.appendChild(no);

      row.appendChild(body);
      row.appendChild(actions);

      li.appendChild(row);
      els.calPendingList.appendChild(li);
    }
  }

  async function confirmPending(id) {
    const res = await fetch(`/api/calendar/pending/${id}/confirm`, { method: "POST" });
    if (res.ok) await loadCalendarFromServer();
  }

  async function rejectPending(id) {
    const res = await fetch(`/api/calendar/pending/${id}/reject`, { method: "POST" });
    if (res.ok) await loadCalendarFromServer();
  }

  function initSectionCollapse() {
    const sections = document.querySelectorAll(".section");
    for (const section of sections) {
      const head = section.querySelector(".panel-head");
      if (!head) continue;
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = "section-toggle";
      btn.setAttribute("aria-label", "toggle section");
      btn.setAttribute("aria-expanded", "true");
      btn.textContent = "▾";
      btn.addEventListener("click", () => {
        const collapsed = section.classList.toggle("collapsed");
        btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
        btn.textContent = collapsed ? "▸" : "▾";
      });
      head.appendChild(btn);

      const shouldStartCollapsed =
        section.classList.contains("dashboard-panel") ||
        section.classList.contains("activity-panel") ||
        section.classList.contains("calendar-panel") ||
        section.classList.contains("scratchpad-panel");
      if (shouldStartCollapsed) {
        section.classList.add("collapsed");
        btn.setAttribute("aria-expanded", "false");
        btn.textContent = "▸";
      }
    }
  }

  function setBusy(on) {
    els.statusPill.classList.toggle("busy", on);
    document.title = on ? "assistant · …" : "assistant";
  }

  function escapeHTML(s) {
    return String(s || "")
      .replaceAll("&", "&amp;")
      .replaceAll("<", "&lt;")
      .replaceAll(">", "&gt;");
  }

  function renderInlineMarkdown(s) {
    let out = escapeHTML(s);
    out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
    out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
    out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
    out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
    return out;
  }

  function markdownTableAt(lines, idx) {
    const hasPipe = (line) => line.includes("|");
    const isSep = (line) => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || "");
    if (idx + 1 >= lines.length) return null;
    if (!hasPipe(lines[idx]) || !isSep(lines[idx + 1])) return null;
    let end = idx + 2;
    while (end < lines.length && hasPipe(lines[end]) && lines[end].trim() !== "") end++;
    return { end };
  }

  function parseTableRow(line) {
    const t = String(line || "").trim();
    const core = t.replace(/^\|/, "").replace(/\|$/, "");
    return core.split("|").map((x) => x.trim());
  }

  function renderMarkdown(text) {
    const src = String(text || "").replace(/\r\n/g, "\n");
    const lines = src.split("\n");
    const html = [];
    let i = 0;
    let inUL = false;
    const closeUL = () => {
      if (inUL) {
        html.push("</ul>");
        inUL = false;
      }
    };
    while (i < lines.length) {
      const line = lines[i];
      const t = line.trim();
      if (!t) {
        closeUL();
        i++;
        continue;
      }
      const tbl = markdownTableAt(lines, i);
      if (tbl) {
        closeUL();
        const header = parseTableRow(lines[i]);
        html.push('<table class="md-table"><thead><tr>');
        for (const c of header) html.push(`<th>${renderInlineMarkdown(c)}</th>`);
        html.push("</tr></thead><tbody>");
        for (let r = i + 2; r < tbl.end; r++) {
          const cols = parseTableRow(lines[r]);
          html.push("<tr>");
          for (const c of cols) html.push(`<td>${renderInlineMarkdown(c)}</td>`);
          html.push("</tr>");
        }
        html.push("</tbody></table>");
        i = tbl.end;
        continue;
      }
      if (t.startsWith("### ")) {
        closeUL();
        html.push(`<h4>${renderInlineMarkdown(t.slice(4))}</h4>`);
        i++;
        continue;
      }
      if (t.startsWith("## ")) {
        closeUL();
        html.push(`<h3>${renderInlineMarkdown(t.slice(3))}</h3>`);
        i++;
        continue;
      }
      if (t.startsWith("# ")) {
        closeUL();
        html.push(`<h2>${renderInlineMarkdown(t.slice(2))}</h2>`);
        i++;
        continue;
      }
      if (t.startsWith("- ") || t.startsWith("* ")) {
        if (!inUL) {
          html.push("<ul>");
          inUL = true;
        }
        html.push(`<li>${renderInlineMarkdown(t.slice(2))}</li>`);
        i++;
        continue;
      }
      closeUL();
      html.push(`<p>${renderInlineMarkdown(t)}</p>`);
      i++;
    }
    closeUL();
    return html.join("");
  }

  function appendBubble(container, role, who, text, opts) {
    const silent = opts && opts.silent;
    const wrap = document.createElement("div");
    wrap.className = "bubble " + role;
    const w = document.createElement("div");
    w.className = "who";
    w.textContent = who;
    const b = document.createElement("div");
    if (role === "assistant") {
      b.className = "md-content";
      b.innerHTML = renderMarkdown(text);
    } else {
      b.textContent = text;
    }
    wrap.appendChild(w);
    wrap.appendChild(b);
    container.appendChild(wrap);
    if (!silent) {
      wrap.scrollIntoView({ block: "nearest" });
    }
  }

  function persistUIState() {
    if (uiRestoring) return;
    try {
      localStorage.setItem(
        UI_STORAGE_KEY,
        JSON.stringify({
          v: 1,
          chat: chatHistory,
          activity: activityHistory,
          summaryMd: summaryMarkdown,
        })
      );
    } catch (_) {}
  }

  function schedulePersistUI() {
    if (uiRestoring) return;
    clearTimeout(persistTimer);
    persistTimer = setTimeout(() => {
      persistTimer = null;
      persistUIState();
    }, 50);
  }

  function loadPersistedUI() {
    let raw;
    try {
      raw = localStorage.getItem(UI_STORAGE_KEY);
    } catch (_) {
      return;
    }
    if (!raw) return;
    let data;
    try {
      data = JSON.parse(raw);
    } catch (_) {
      return;
    }
    if (!data || data.v !== 1) return;
    uiRestoring = true;
    try {
      els.chatLog.innerHTML = "";
      els.activityLog.innerHTML = "";
      chatHistory.length = 0;
      activityHistory.length = 0;
      for (const m of data.chat || []) {
        if (m && m.role && m.who != null && m.text != null) {
          appendBubble(els.chatLog, m.role, m.who, m.text, { silent: true });
          chatHistory.push({ role: m.role, who: m.who, text: m.text });
        }
      }
      for (const m of data.activity || []) {
        if (m && m.who != null && m.text != null) {
          appendBubble(els.activityLog, "assistant", m.who, m.text, { silent: true });
          activityHistory.push({ who: m.who, text: m.text });
        }
      }
      summaryMarkdown = typeof data.summaryMd === "string" ? data.summaryMd : "";
      if (els.summary) {
        if (summaryMarkdown) {
          els.summary.classList.add("md-content");
          els.summary.innerHTML = renderMarkdown(summaryMarkdown);
        } else {
          els.summary.textContent = "";
          els.summary.classList.remove("md-content");
        }
      }
    } finally {
      uiRestoring = false;
    }
  }

  function appendChat(role, who, text) {
    appendBubble(els.chatLog, role, who, text);
    chatHistory.push({ role, who, text });
    schedulePersistUI();
  }

  function appendActivity(who, text) {
    appendBubble(els.activityLog, "assistant", who, text);
    activityHistory.push({ who, text });
    schedulePersistUI();
  }

  function clearUIChat() {
    els.chatLog.innerHTML = "";
    els.activityLog.innerHTML = "";
    if (els.summary) {
      els.summary.textContent = "";
      els.summary.classList.remove("md-content");
    }
    chatHistory.length = 0;
    activityHistory.length = 0;
    summaryMarkdown = "";
    persistUIState();
  }

  function appendAssistantThinking() {
    const wrap = document.createElement("div");
    wrap.className = "bubble assistant thinking";
    const w = document.createElement("div");
    w.className = "who";
    w.textContent = "assistant";
    const b = document.createElement("div");
    b.className = "body";
    b.textContent = "thinking";
    wrap.appendChild(w);
    wrap.appendChild(b);
    els.chatLog.appendChild(wrap);
    wrap.scrollIntoView({ block: "nearest" });
    return { wrap, textEl: b };
  }

  function eventBody(ev) {
    switch (ev.type) {
      case "phase":
        return ev.message || ev.detail || "";
      case "llm_request":
        return `round ${ev.round}`;
      case "llm_reply":
        return (
          `round ${ev.round} · pending tools: ${ev.tool_calls || 0}` +
          (ev.has_content && ev.preview ? `\n${ev.preview}` : "")
        );
      case "tool_call":
        return `${ev.tool}\nargs: ${ev.args || "{}"}`;
      case "tool_result":
        return `${ev.tool} · ${ev.ok ? "ok" : "error"}\n${ev.preview || ""}`;
      case "final":
        return "done";
      case "error":
        return ev.message || "";
      default:
        return JSON.stringify(ev);
    }
  }

  function eventTitle(ev) {
    switch (ev.type) {
      case "phase":
        return "prepare";
      case "llm_request":
        return "calling model";
      case "llm_reply":
        return "model reply";
      case "tool_call":
        return "tool call";
      case "tool_result":
        return "tool result";
      case "final":
        return "done";
      case "error":
        return "error";
      default:
        return ev.type || "event";
    }
  }

  function stopScratchpadTimers() {
    clearTimeout(scratchpadSaveTimer);
    scratchpadSaveTimer = null;
    if (scratchpadPollTimer) {
      clearInterval(scratchpadPollTimer);
      scratchpadPollTimer = null;
    }
  }

  function refreshScratchpadRenderedIfPreview() {
    if (!scratchpadRenderPreview || !els.scratchpadRendered || !els.scratchpadEditor) return;
    els.scratchpadRendered.innerHTML = renderMarkdown(els.scratchpadEditor.value);
  }

  function applyScratchpadRenderUI() {
    if (!els.scratchpadEditor || !els.scratchpadRendered || !els.scratchpadRenderToggle) return;
    if (scratchpadRenderPreview) {
      refreshScratchpadRenderedIfPreview();
      els.scratchpadEditor.setAttribute("hidden", "");
      els.scratchpadRendered.removeAttribute("hidden");
      els.scratchpadRenderToggle.textContent = "Edit";
      els.scratchpadRenderToggle.setAttribute("aria-pressed", "true");
      els.scratchpadRenderToggle.setAttribute("aria-label", "Edit Markdown source");
    } else {
      els.scratchpadEditor.removeAttribute("hidden");
      els.scratchpadRendered.setAttribute("hidden", "");
      els.scratchpadRenderToggle.textContent = "Preview";
      els.scratchpadRenderToggle.setAttribute("aria-pressed", "false");
      els.scratchpadRenderToggle.setAttribute("aria-label", "Show rendered Markdown");
    }
  }

  function startScratchpadPolling() {
    if (!els.scratchpadPanel || els.scratchpadPanel.hidden || scratchpadPollTimer) return;
    scratchpadPollTimer = setInterval(() => {
      if (!els.scratchpadPanel || els.scratchpadPanel.hidden) return;
      if (document.activeElement === els.scratchpadEditor) return;
      pullScratchpadFromServerIfIdle().catch(() => {});
    }, 4000);
  }

  async function pullScratchpadFromServerIfIdle() {
    if (!els.scratchpadEditor || document.activeElement === els.scratchpadEditor) return;
    const res = await fetch("/api/scratchpad", { cache: "no-store" });
    if (!res.ok) return;
    const data = await res.json().catch(() => ({}));
    const c = typeof data.content === "string" ? data.content : "";
    if (els.scratchpadEditor.value !== c) {
      els.scratchpadEditor.value = c;
      refreshScratchpadRenderedIfPreview();
    }
  }

  function setScratchpadFromStatus(enabled) {
    if (!els.scratchpadPanel) return;
    if (enabled) {
      els.scratchpadPanel.hidden = false;
      if (!scratchpadLoadedOnce) {
        scratchpadLoadedOnce = true;
        loadScratchpad().catch(() => {});
      }
      if (!scratchpadPollTimer) startScratchpadPolling();
    } else {
      els.scratchpadPanel.hidden = true;
      scratchpadLoadedOnce = false;
      stopScratchpadTimers();
    }
  }

  async function loadScratchpad() {
    if (!els.scratchpadEditor) return;
    const res = await fetch("/api/scratchpad", { cache: "no-store" });
    if (!res.ok) {
      if (els.scratchpadHint) els.scratchpadHint.textContent = res.status === 404 ? "" : `load failed (${res.status})`;
      return;
    }
    const data = await res.json().catch(() => ({}));
    els.scratchpadEditor.value = typeof data.content === "string" ? data.content : "";
    applyScratchpadRenderUI();
    if (els.scratchpadHint) els.scratchpadHint.textContent = "";
  }

  async function pushScratchpadSave() {
    if (!els.scratchpadEditor) return;
    const content = els.scratchpadEditor.value;
    const res = await fetch("/api/scratchpad", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content }),
    });
    if (!res.ok) {
      const data = await res.json().catch(() => ({}));
      const msg = data.error || (await res.text().catch(() => "")) || res.statusText;
      if (els.scratchpadHint) els.scratchpadHint.textContent = msg || `save failed (${res.status})`;
      return;
    }
    if (els.scratchpadHint) els.scratchpadHint.textContent = "";
  }

  function onScratchpadInput() {
    if (!els.scratchpadEditor) return;
    clearTimeout(scratchpadSaveTimer);
    scratchpadSaveTimer = setTimeout(() => {
      scratchpadSaveTimer = null;
      pushScratchpadSave().catch(() => {});
    }, 450);
  }

  function renderWidgets(status) {
    els.widgets.innerHTML = "";
    const widgets = [
      { type: "status", title: "overview" },
      { type: "automations", title: "automations" },
      { type: "tools", title: "tools" },
      { type: "memory_context", title: "memory" },
    ];

    for (const w of widgets) {
      const card = document.createElement("div");
      card.className = "widget-card";
      const title = document.createElement("div");
      title.className = "widget-title";
      title.textContent = w.title;
      card.appendChild(title);

      if (w.type === "status") {
        const lines = document.createElement("div");
        lines.className = "status-lines";
        lines.textContent =
          `status: ${status.status || "idle"}\ncurrent prompt: ${status.current_prompt || "-"}\n` +
          `uptime: ${status.uptime_seconds || 0}s`;
        card.appendChild(lines);
      } else if (w.type === "tools") {
        const table = document.createElement("table");
        table.className = "kv-table";
        table.innerHTML = "<thead><tr><th>Tool</th><th>Calls</th><th>avg</th></tr></thead>";
        const body = document.createElement("tbody");
        const rows = [];
        const llm = status.llm || {};
        rows.push({
          name: "llm",
          count: Number(llm.count || 0),
          avg_ms: Number(llm.avg_ms || 0),
        });
        for (const r of status.tools?.by_name || []) {
          rows.push(r);
        }
        for (let i = 0; i < Math.min(8, rows.length); i++) {
          const r = rows[i];
          const tr = document.createElement("tr");
          tr.innerHTML = `<td>${r.name}</td><td>${r.count}</td><td>${Number(r.avg_ms || 0).toFixed(1)}</td>`;
          body.appendChild(tr);
        }
        table.appendChild(body);
        card.appendChild(table);
      } else if (w.type === "automations") {
        const list = document.createElement("div");
        list.className = "status-lines";
        const autos = status.automations || [];
        list.textContent = autos.length
          ? autos
              .map((a) => `- ${a.name}\n  next: ${a.next_run || "unknown"}\n  last: ${a.last_run || "-"}`)
              .join("\n\n")
          : "no automations configured.";
        card.appendChild(list);
      } else if (w.type === "memory_context") {
        const s = status.memory_store || {};
        const h = status.history || {};
        const c = status.context_consumption || {};
        const line = document.createElement("div");
        line.className = "status-lines";
        line.textContent =
          `context used: ${Number(c.used_chars || 0).toLocaleString()} chars\n` +
          `chat session: ${Number(h.count || 0)} msgs, ${Number(h.total_chars || 0).toLocaleString()} chars\n` +
          `long memory: ${Number(s.long_count || 0)} items, ${Number(s.long_chars || 0).toLocaleString()} chars`;
        card.appendChild(line);
      }

      els.widgets.appendChild(card);
    }
  }

  function handleSSEEvent(ev) {
    appendActivity(eventTitle(ev), eventBody(ev));
    if (ev.type === "final") {
      summaryMarkdown = ev.text || "";
      if (els.summary) {
        els.summary.classList.add("md-content");
        els.summary.innerHTML = renderMarkdown(summaryMarkdown);
      }
      schedulePersistUI();
    }
    if (ev.type === "error") {
      const msg = ev.message || "Unknown error";
      summaryMarkdown = summaryMarkdown ? `${summaryMarkdown}\n\n${msg}` : msg;
      if (els.summary) {
        els.summary.classList.add("md-content");
        els.summary.innerHTML = renderMarkdown(summaryMarkdown);
      }
      schedulePersistUI();
      appendChat("assistant", "assistant", `error: ${msg}`);
    }
  }

  async function fetchStatusAndUpdate() {
    const res = await fetch("/api/status", { cache: "no-store" });
    if (!res.ok) return;
    const st = await res.json();
    const working = st.status === "working";
    setBusy(working);
    els.statusText.textContent = working ? "working..." : "ready";
    const model = (st.model || "").trim();
    els.modelLine.textContent = model ? `model: ${model}` : "model: -";
    renderWidgets(st);
    const sp = st.scratchpad && st.scratchpad.enabled;
    setScratchpadFromStatus(!!sp);
    if (els.calGrid) {
      const cr = Number(st.calendar_revision);
      if (Number.isFinite(cr) && cr !== calState.lastRevision) {
        await loadCalendarFromServer();
      }
    }
  }

  function startStatusPolling() {
    fetchStatusAndUpdate().catch(() => {});
    window.__statusPoll = setInterval(() => {
      fetchStatusAndUpdate().catch(() => {});
    }, 2000);
  }

  /** Set while a chat request is in flight; cancel calls `.abort()` to drop the stream. */
  let askAbortController = null;

  async function runPrompt(text) {
    let thinking = null;
    let thinkingInterval = null;
    const stopThinking = () => {
      if (thinkingInterval) clearInterval(thinkingInterval);
      if (thinking?.wrap && thinking.wrap.parentNode) thinking.wrap.parentNode.removeChild(thinking.wrap);
      thinking = null;
      thinkingInterval = null;
    };

    const ac = new AbortController();
    askAbortController = ac;
    if (els.cancel) els.cancel.disabled = false;

    setBusy(true);
    els.statusText.textContent = "working...";
    summaryMarkdown = "";
    if (els.summary) {
      els.summary.textContent = "";
      els.summary.classList.remove("md-content");
    }
    persistUIState();
    const isClearAll = text === "/clear";
    if (isClearAll) {
      clearUIChat();
    } else {
      appendChat("user", "you", text);
    }
    thinking = appendAssistantThinking();
    let dots = 0;
    thinkingInterval = setInterval(() => {
      dots = (dots + 1) % 4;
      thinking.textEl.textContent = "thinking" + ".".repeat(dots);
    }, 450);

    try {
      const res = await fetch("/ask/stream", {
        method: "POST",
        headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
        body: JSON.stringify({ prompt: text }),
        signal: ac.signal,
      });

      if (!res.ok || !res.body) {
        const err = await res.text();
        stopThinking();
        appendActivity("error", err || res.statusText);
        setBusy(false);
        els.statusText.textContent = "ready";
        return;
      }

      const reader = res.body.getReader();
      const dec = new TextDecoder();
      let buf = "";
      let finalText = "";
      try {
        for (;;) {
          const { done, value } = await reader.read();
          if (done) break;
          buf += dec.decode(value, { stream: true });
          const parts = buf.split("\n\n");
          buf = parts.pop() || "";
          for (const block of parts) {
            const line = block.trim();
            if (!line.startsWith("data:")) continue;
            try {
              const ev = JSON.parse(line.slice(5).trim());
              handleSSEEvent(ev);
              if (ev.type === "final") finalText = ev.text || "";
            } catch {}
          }
        }
      } finally {
        try {
          reader.releaseLock();
        } catch (_) {}
      }

      stopThinking();
      if (finalText) appendChat("assistant", "assistant", finalText);
      setBusy(false);
      els.statusText.textContent = "ready";
      await fetchStatusAndUpdate();
    } catch (err) {
      stopThinking();
      if (!ac.signal.aborted) {
        appendActivity("error", String(err));
      }
      setBusy(false);
      els.statusText.textContent = "ready";
      await fetchStatusAndUpdate();
    } finally {
      askAbortController = null;
      if (els.cancel) els.cancel.disabled = true;
    }
  }

  if (els.cancel) {
    els.cancel.addEventListener("click", () => {
      if (askAbortController) askAbortController.abort();
    });
  }

  els.form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const text = els.prompt.value.trim();
    if (!text) return;
    els.prompt.value = "";
    els.send.disabled = true;
    try {
      await runPrompt(text);
    } catch (err) {
      appendActivity("error", String(err));
    } finally {
      els.send.disabled = false;
      els.prompt.focus();
    }
  });

  els.prompt.addEventListener("keydown", (e) => {
    if (e.key !== "Enter" || e.shiftKey) return;
    e.preventDefault();
    if (els.send.disabled) return;
    if (els.form.requestSubmit) {
      els.form.requestSubmit();
      return;
    }
    els.form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
  });

  if (els.calPrev) {
    els.calPrev.addEventListener("click", () => {
      calState.viewMonth--;
      if (calState.viewMonth < 0) {
        calState.viewMonth = 11;
        calState.viewYear--;
      }
      loadCalendarFromServer();
    });
  }
  if (els.calNext) {
    els.calNext.addEventListener("click", () => {
      calState.viewMonth++;
      if (calState.viewMonth > 11) {
        calState.viewMonth = 0;
        calState.viewYear++;
      }
      loadCalendarFromServer();
    });
  }
  if (els.calToday) {
    els.calToday.addEventListener("click", () => {
      const t = new Date();
      calState.viewYear = t.getFullYear();
      calState.viewMonth = t.getMonth();
      calState.selectedKey = isoDateLocal(t);
      loadCalendarFromServer();
    });
  }
  if (els.calQuickWhen) {
    els.calQuickWhen.addEventListener("change", () => {
      const v = els.calQuickWhen.value;
      if (v === CAL_WHEN_UNDATED) return;
      if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return;
      if (v === calState.selectedKey) return;
      const parts = v.split("-").map((x) => parseInt(x, 10));
      calState.viewYear = parts[0];
      calState.viewMonth = parts[1] - 1;
      calState.selectedKey = v;
      loadCalendarFromServer();
    });
  }
  if (els.calQuickForm) {
    els.calQuickForm.addEventListener("submit", async (e) => {
      e.preventDefault();
      const title = (els.calQuickTitle && els.calQuickTitle.value.trim()) || "";
      if (!title) return;
      const whenVal = (els.calQuickWhen && els.calQuickWhen.value) || "";
      const body = {
        title,
        notes: "",
        status: "open",
        due_at: "",
      };
      if (whenVal === CAL_WHEN_UNDATED) {
        body.due_at = "";
      } else if (/^\d{4}-\d{2}-\d{2}$/.test(whenVal)) {
        body.due_at = whenVal;
      } else {
        return;
      }
      const res = await fetch("/api/calendar/items", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (res.ok) {
        if (els.calQuickTitle) els.calQuickTitle.value = "";
        const data = await res.json().catch(() => ({}));
        if (data.revision != null) calState.lastRevision = Number(data.revision);
        await loadCalendarFromServer();
      }
    });
  }

  if (els.scratchpadEditor) {
    els.scratchpadEditor.addEventListener("input", onScratchpadInput);
    els.scratchpadEditor.addEventListener("blur", () => {
      clearTimeout(scratchpadSaveTimer);
      scratchpadSaveTimer = null;
      pushScratchpadSave().catch(() => {});
    });
  }
  if (els.scratchpadRenderToggle) {
    els.scratchpadRenderToggle.addEventListener("click", () => {
      if (!scratchpadRenderPreview) {
        clearTimeout(scratchpadSaveTimer);
        scratchpadSaveTimer = null;
        pushScratchpadSave().catch(() => {});
      }
      scratchpadRenderPreview = !scratchpadRenderPreview;
      try {
        sessionStorage.setItem(SCRATCHPAD_RENDER_KEY, scratchpadRenderPreview ? "1" : "0");
      } catch (_) {}
      applyScratchpadRenderUI();
    });
    applyScratchpadRenderUI();
  }

  if (!calState.selectedKey) calState.selectedKey = isoDateLocal(new Date());
  rebuildCalWhenSelect();
  loadCalendarFromServer();

  els.statusText.textContent = "ready";
  els.modelLine.textContent = "model: -";
  initSectionCollapse();
  loadPersistedUI();
  startStatusPolling();
})();