/*
  Client-side behaviour.

  Everything here is progressive: the application is server-rendered and works
  without this file, apart from the streaming reply, which is htmx's SSE
  extension rather than anything hand-written below.
*/
(function () {
  "use strict";

  var THEME_KEY = "lembas-theme";

  /* --- Theme -------------------------------------------------------------
     Stored locally so the choice applies instantly and survives being signed
     out, and mirrored to the server so it follows the user to another device.
     The server call is best-effort: a failure must not undo the local switch.

     The list used to be a literal pair here, and in four other places. It comes
     from `data-themes` on <html> now -- "id:base" pairs, space separated --
     because an administrator can define one, and a hard-coded pair would refuse
     it silently: applyTheme would return, the button would do nothing, and
     nothing anywhere would say why. */
  function themes() {
    var raw = document.documentElement.dataset.themes || "moria:moria shire:shire";
    var map = {};
    raw.split(/\s+/).forEach(function (entry) {
      var parts = entry.split(":");
      if (parts[0]) map[parts[0]] = parts[1] || "moria";
    });
    return map;
  }

  function themeNames() {
    return Object.keys(themes());
  }

  function currentTheme() {
    return document.documentElement.dataset.theme || themeNames()[0];
  }

  function applyTheme(name) {
    var known = themes();
    if (!Object.prototype.hasOwnProperty.call(known, name)) return;
    document.documentElement.dataset.theme = name;
    /* Both attributes, always. `data-base` is what makes a custom theme inherit
       its built-in palette -- tokens.css matches it as well as `data-theme` --
       so setting only the first leaves a custom light theme's four colours on
       Moria's near-black surfaces. */
    document.documentElement.dataset.base = known[name];
    try {
      localStorage.setItem(THEME_KEY, name);
    } catch (e) { /* private mode */ }

    /* Installed, the browser's own chrome is the application's chrome, so it
       has to follow the theme too. Read from the stylesheet rather than
       repeating the hex here: tokens.css is the one place colours live. */
    var meta = document.querySelector('meta[name="theme-color"]');
    if (meta) {
      var bg = getComputedStyle(document.documentElement)
        .getPropertyValue("--bg").trim();
      if (bg) meta.setAttribute("content", bg);
    }

    /* The toggle names where it is going, not where it is. With more than two
       themes "the next one" is the honest description, because naming it would
       mean carrying every label into the browser for a label nobody reads
       twice. */
    document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
      el.setAttribute("aria-label", known[name] === "moria" ? "Switch to the light theme"
                                                            : "Switch to the dark theme");
    });

    /* For anything holding colours as values rather than reading them from a
       variable. The terminal is the only such thing: xterm copies its palette
       at construction, so switching to Shire would otherwise leave a black
       rectangle in a light interface. */
    document.dispatchEvent(new CustomEvent("lembas:theme", { detail: { theme: name } }));

    if (document.body.dataset.authenticated === "true") {
      fetch("/api/preferences/theme", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ theme: name })
      }).catch(function () { /* preference is already applied locally */ });
    }
  }

  /* Round the list rather than between two names. With only the built-in pair
     this is exactly what it always did; with a third defined it reaches it,
     which a hard-coded flip never could. */
  function toggleTheme() {
    var names = themeNames();
    var at = names.indexOf(currentTheme());
    applyTheme(names[(at + 1) % names.length] || names[0]);
  }

  /* --- Textarea autosize -------------------------------------------------
     Grows the composer with its content up to a cap, after which it scrolls. */
  function autosize(el) {
    if (!el) return;
    var max = parseInt(el.dataset.maxHeight || "320", 10);
    el.style.height = "auto";
    el.style.height = Math.min(el.scrollHeight, max) + "px";
    el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
  }

  /* --- Copy --------------------------------------------------------------
     Falls back to a hidden textarea because navigator.clipboard is unavailable
     on pages served over plain http, which self-hosted installs often are. */
  function copyText(text, trigger) {
    function done() {
      if (!trigger) return;
      var original = trigger.getAttribute("aria-label");
      trigger.classList.add("is-copied");
      trigger.setAttribute("aria-label", "Copied");
      setTimeout(function () {
        trigger.classList.remove("is-copied");
        if (original) trigger.setAttribute("aria-label", original);
      }, 1400);
    }

    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard.writeText(text).then(done).catch(function () {});
      return;
    }
    var scratch = document.createElement("textarea");
    scratch.value = text;
    scratch.setAttribute("readonly", "");
    scratch.style.position = "fixed";
    scratch.style.opacity = "0";
    document.body.appendChild(scratch);
    scratch.select();
    try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ }
    document.body.removeChild(scratch);
  }

  /* --- Thread scrolling --------------------------------------------------
     Only auto-scrolls when the reader is already near the bottom, so scrolling
     up to re-read something is not yanked away by an incoming token. */
  var STICK_THRESHOLD = 120;

  function isNearBottom(el) {
    return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
  }

  /* Whether the view is following the reply. Distance to the bottom used to be
     the whole test, and it very nearly is -- but somebody near the bottom who
     opens a tool block is *reading*, and the next frame eighty milliseconds
     later dragged them straight back down again. At twelve frames a second that
     reads as a block that will not open at all.

     So opening one turns following off, and returning to the bottom turns it
     back on. `stick` is the gate; `isNearBottom` is what maintains it. */
  var stick = true;
  /* Where scrollThread last put it, so a scroll event can be told apart from a
     scroll the reader did. Without this the programmatic scroll re-arms `stick`
     on its own and nothing ever unsticks. */
  var placed = -1;

  function scrollThread(force) {
    var thread = document.getElementById("thread-scroll");
    if (!thread) return;
    if (force) stick = true;
    if (stick) {
      thread.scrollTop = thread.scrollHeight;
      placed = thread.scrollTop;
    }
  }

  document.addEventListener(
    "scroll",
    function (event) {
      var thread = event.target;
      if (!thread || thread.id !== "thread-scroll") return;
      if (thread.scrollTop === placed) return;
      stick = isNearBottom(thread);
    },
    true
  );

  /* --- Reading backwards -------------------------------------------------
     The mirror of `scrollThread`. Messages pages in older turns above the ones
     on screen, and *prepending* moves everything down by the height of what
     arrived -- so without this the reader is dragged up the page the instant
     the sentinel fires, which reads as a browser bug rather than as a feature.

     Height is recorded before the swap and the difference added back after, so
     whatever was under the reader's eye stays there. `overflow-anchor` is not
     reliable across an htmx swap, and `stick` deliberately is not touched:
     reading history is not following a reply, and re-arming it here would jump
     to the bottom the moment the next page landed. */
  var heightBefore = -1;

  document.body.addEventListener("htmx:beforeSwap", function (event) {
    var el = event.target;
    if (!el || !el.classList || !el.classList.contains("history-sentinel")) return;
    var thread = document.getElementById("thread-scroll");
    heightBefore = thread ? thread.scrollHeight : -1;
  });

  document.body.addEventListener("htmx:afterSwap", function (event) {
    if (heightBefore < 0) return;
    var thread = document.getElementById("thread-scroll");
    if (thread) {
      thread.scrollTop += thread.scrollHeight - heightBefore;
      placed = thread.scrollTop;
    }
    heightBefore = -1;
  });

  /* `toggle` does not bubble, so this has to be registered in the CAPTURE phase.
     Without the third argument the listener is never called and the whole thing
     is silently dead in every browser -- the same shape of failure as a trigger
     bound where the event does not go. There is a test on it. */
  document.addEventListener(
    "toggle",
    function (event) {
      var el = event.target;
      if (!el || el.tagName !== "DETAILS" || !el.open) return;
      if (el.closest && el.closest("#thread-scroll")) stick = false;
    },
    true
  );

  /* --- Attachments -------------------------------------------------------
     Files are uploaded one at a time as soon as they are chosen, dropped or
     pasted, rather than all at once when the message is sent. The chip (or the
     rejection) then appears immediately, and a large file cannot make the send
     button appear to hang. */
  function uploadFiles(fileList) {
    var input = document.getElementById("file-input");
    var target = document.getElementById("attachments");
    if (!input || !target || !fileList || !fileList.length) return;

    var url = input.dataset.uploadUrl;

    Array.prototype.forEach.call(fileList, function (file) {
      var body = new FormData();
      body.append("file", file, file.name);

      fetch(url, { method: "POST", body: body, credentials: "same-origin" })
        .then(function (response) { return response.text(); })
        .then(function (html) {
          target.insertAdjacentHTML("beforeend", html);
          // The chip's remove button is htmx-driven, so the new markup has to
          // be announced or its attributes are inert.
          if (window.htmx) window.htmx.process(target.lastElementChild);
        })
        .catch(function () {
          target.insertAdjacentHTML(
            "beforeend",
            '<div class="attach-chip attach-chip--error">' +
              '<span class="attach-chip__body">' +
              '<span class="attach-chip__name"></span>' +
              '<span class="attach-chip__warning">Upload failed.</span></span></div>'
          );
          // Set as text, never as HTML: the filename comes from the user.
          target.lastElementChild.querySelector(".attach-chip__name").textContent =
            file.name;
        });
    });
  }

  /* --- Attaching something that is not a file ----------------------------
     The composer's menu offers four things; two of them are the file picker
     with a different filter, and two need a round trip. Both of those post a
     form and get a chip back, exactly like an upload, so the composer does not
     have to know where a chip came from. */
  function chipTarget() {
    return document.getElementById("attachments");
  }

  function chatId() {
    var input = document.getElementById("file-input");
    var url = (input && input.dataset.uploadUrl) || "";
    var match = url.match(/chat_id=([^&]+)/);
    return match ? decodeURIComponent(match[1]) : "";
  }

  function postForChip(url, body) {
    var target = chipTarget();
    if (!target) return Promise.resolve();
    return fetch(url, { method: "POST", body: body, credentials: "same-origin" })
      .then(function (response) { return response.text(); })
      .then(function (html) {
        target.insertAdjacentHTML("beforeend", html);
        if (window.htmx) window.htmx.process(target.lastElementChild);
      });
  }

  function attachLink() {
    if (!window.lembas || !window.lembas.prompt) return;
    window.lembas.prompt({
      title: "Attach a web page",
      message: "The page is fetched now and its text attached, so it will not " +
               "change between writing this and sending it.",
      placeholder: "https://example.com/article",
      confirmLabel: "Fetch",
    }).then(function (url) {
      if (!url || !url.trim()) return;
      var body = new FormData();
      body.append("url", url.trim());
      body.append("chat_id", chatId());
      return postForChip("/api/files/link", body);
    });
  }

  /* The knowledge dialog re-queries the server as you type rather than
     filtering in the browser: the library is searched with FTS, which is what
     makes it work at five hundred documents instead of five. */
  function attachKnowledge() {
    var dialog = document.createElement("dialog");
    dialog.className = "dialog dialog--wide";
    dialog.innerHTML =
      '<div class="dialog__form">' +
      '<h2 class="dialog__title">Attach from your library</h2>' +
      '<input class="input" type="search" placeholder="Search your documents…" ' +
      'aria-label="Search your documents">' +
      '<div class="dialog__results"></div>' +
      '<div class="dialog__actions"><button class="btn" type="button">Close</button></div>' +
      "</div>";
    document.body.appendChild(dialog);

    var search = dialog.querySelector("input");
    var results = dialog.querySelector(".dialog__results");
    var close = dialog.querySelector("button");

    function load(query) {
      fetch("/api/files/knowledge-picker?q=" + encodeURIComponent(query || ""), {
        credentials: "same-origin",
      })
        .then(function (response) { return response.text(); })
        .then(function (html) { results.innerHTML = html; })
        .catch(function () { results.textContent = "Could not load your library."; });
    }

    var pending = null;
    search.addEventListener("input", function () {
      clearTimeout(pending);
      pending = setTimeout(function () { load(search.value); }, 200);
    });

    results.addEventListener("click", function (event) {
      var option = event.target.closest("[data-attach-knowledge]");
      if (!option) return;
      var body = new FormData();
      body.append("document_id", option.dataset.attachKnowledge);
      body.append("chat_id", chatId());
      postForChip("/api/files/from-knowledge", body);
      finish();
    });

    function finish() {
      dialog.close();
      setTimeout(function () { dialog.remove(); }, 200);
    }

    close.addEventListener("click", finish);
    dialog.addEventListener("cancel", function (event) {
      event.preventDefault();
      finish();
    });
    dialog.addEventListener("click", function (event) {
      if (event.target === dialog) finish();
    });

    dialog.showModal();
    load("");
    search.focus();
  }

  /* --- Choosing a directory on the far side --------------------------------
     Shaped like attachKnowledge above, with one difference: a click walks
     deeper rather than finishing, and finishing is its own button. The path
     that gets submitted is the directory you are *standing in*, not the last
     row you pressed, so choosing the directory you are already looking at
     needs no click at all. */
  function chooseDirectory(profileId, current, onPick) {
    var dialog = document.createElement("dialog");
    dialog.className = "dialog dialog--wide";
    dialog.innerHTML =
      '<div class="dialog__form">' +
      '<h2 class="dialog__title">Project directory</h2>' +
      '<p class="dialog__note">Where this chat starts, and what a relative path ' +
      "is measured from. You can walk anywhere the account can reach.</p>" +
      '<div class="dialog__results"></div>' +
      '<input class="input input--mono" type="text" spellcheck="false" ' +
      'aria-label="Path" placeholder="/project">' +
      '<div class="dialog__actions">' +
      '<button class="btn" type="button" data-dir-cancel>Cancel</button>' +
      '<button class="btn btn--primary" type="button" data-dir-use>Use this directory</button>' +
      "</div></div>";
    document.body.appendChild(dialog);

    var results = dialog.querySelector(".dialog__results");
    var typed = dialog.querySelector("input");
    var here = current || "";

    function load(path) {
      results.setAttribute("aria-busy", "true");
      fetch(
        "/api/agents/" + encodeURIComponent(profileId) +
          "/browse?path=" + encodeURIComponent(path || ""),
        { credentials: "same-origin" }
      )
        .then(function (response) { return response.text(); })
        .then(function (html) {
          results.innerHTML = html;
          var box = results.querySelector("#dir-results");
          /* The server decides where we ended up -- it resolved the empty
             path to the profile's own default -- so the typed field follows
             it rather than the other way round. */
          if (box) { here = box.dataset.here || path || ""; typed.value = here; }
          results.removeAttribute("aria-busy");
        })
        .catch(function () {
          results.textContent = "Could not reach that machine.";
          results.removeAttribute("aria-busy");
        });
    }

    results.addEventListener("click", function (event) {
      var row = event.target.closest("[data-dir-open]");
      if (!row) return;
      load(row.dataset.dirOpen);
    });

    /* Typing a path you already know beats clicking to it, so the field is a
       first-class way in and not only a display of where you are. */
    typed.addEventListener("keydown", function (event) {
      if (event.key !== "Enter") return;
      event.preventDefault();
      load(typed.value.trim());
    });

    function finish(chosen) {
      if (chosen !== undefined) onPick(chosen);
      dialog.close();
      setTimeout(function () { dialog.remove(); }, 200);
    }

    dialog.querySelector("[data-dir-use]").addEventListener("click", function () {
      finish(typed.value.trim() || here);
    });
    dialog.querySelector("[data-dir-cancel]").addEventListener("click", function () {
      finish();
    });
    dialog.addEventListener("cancel", function (event) {
      event.preventDefault();
      finish();
    });
    dialog.addEventListener("click", function (event) {
      if (event.target === dialog) finish();
    });

    dialog.showModal();
    load(current || "");
  }

  /* --- Choosing a file on the far side -------------------------------------
     The same walk as chooseDirectory, finishing on a file rather than on a
     button. Canvas used to ask for a typed path, which is the one thing in this
     application that expected somebody to remember an absolute path on another
     machine -- the same complaint the folder page's directory box answered.

     A separate function rather than a flag on the one above, because almost
     everything differs: what a click does, what finishes it, whether there is a
     "use this" button at all, and what the dialog is called. What they share is
     the listing, and that is shared where it matters -- one fragment on the
     server, asked for with `pick=file`. */
  function chooseFile(profileId, current, onPick) {
    var dialog = document.createElement("dialog");
    dialog.className = "dialog dialog--wide";
    dialog.innerHTML =
      '<div class="dialog__form">' +
      '<h2 class="dialog__title">Open a file</h2>' +
      '<p class="dialog__note">Pick a file to open in the canvas. Folders walk ' +
      "deeper; you can also type a path and press Enter.</p>" +
      '<div class="dialog__results"></div>' +
      '<input class="input input--mono" type="text" spellcheck="false" ' +
      'aria-label="Path" placeholder="/project/src/main.py">' +
      '<div class="dialog__actions">' +
      '<button class="btn" type="button" data-file-cancel>Cancel</button>' +
      "</div></div>";
    document.body.appendChild(dialog);

    var results = dialog.querySelector(".dialog__results");
    var typed = dialog.querySelector("input");
    var here = current || "";

    function load(path) {
      results.setAttribute("aria-busy", "true");
      fetch(
        "/api/agents/" + encodeURIComponent(profileId) +
          "/browse?pick=file&path=" + encodeURIComponent(path || ""),
        { credentials: "same-origin" }
      )
        .then(function (response) { return response.text(); })
        .then(function (html) {
          results.innerHTML = html;
          var box = results.querySelector("#dir-results");
          if (box) { here = box.dataset.here || path || ""; typed.value = here; }
          results.removeAttribute("aria-busy");
        })
        .catch(function () {
          results.textContent = "Could not reach that machine.";
          results.removeAttribute("aria-busy");
        });
    }

    function finish(chosen) {
      if (chosen !== undefined) onPick(chosen);
      dialog.close();
      setTimeout(function () { dialog.remove(); }, 200);
    }

    results.addEventListener("click", function (event) {
      /* A file finishes; a folder is a step. Checked in that order because a
         row is one or the other and the file case is what this dialog is for. */
      var file = event.target.closest("[data-file-open]");
      if (file) { finish(file.dataset.fileOpen); return; }
      var row = event.target.closest("[data-dir-open]");
      if (row) load(row.dataset.dirOpen);
    });

    /* Enter opens what was typed if it looks like a file, and walks into it
       otherwise. There is no way to tell from here which it is, so the server
       decides: a path that lists is a directory and the listing comes back; one
       that does not is taken as a file. Cheaper than a second endpoint asking
       "what is this", and wrong only for a directory that cannot be read --
       which the canvas then reports in its own words. */
    typed.addEventListener("keydown", function (event) {
      if (event.key !== "Enter") return;
      event.preventDefault();
      var value = typed.value.trim();
      if (value && value !== here) { finish(value); return; }
      load(value);
    });

    dialog.querySelector("[data-file-cancel]").addEventListener("click", function () {
      finish();
    });
    dialog.addEventListener("cancel", function (event) {
      event.preventDefault();
      finish();
    });
    dialog.addEventListener("click", function (event) {
      if (event.target === dialog) finish();
    });

    dialog.showModal();
    load(current || "");
  }

  document.addEventListener("click", function (event) {
    var choice = event.target.closest("[data-attach]");
    if (!choice) return;
    event.preventDefault();

    var kind = choice.dataset.attach;
    if (kind === "file") document.getElementById("file-input").click();
    else if (kind === "image") document.getElementById("image-input").click();
    else if (kind === "link") attachLink();
    else if (kind === "knowledge") attachKnowledge();
  });

  function setupDropzone() {
    var zone = document.querySelector("[data-dropzone]");
    if (!zone) return;

    /* dragenter/dragleave fire for every child element the pointer crosses, so
       a plain toggle flickers. Counting entries and exits is the standard fix. */
    var depth = 0;

    function hasFiles(event) {
      return event.dataTransfer && Array.prototype.indexOf.call(
        event.dataTransfer.types || [], "Files"
      ) !== -1;
    }

    zone.addEventListener("dragenter", function (event) {
      if (!hasFiles(event)) return;
      event.preventDefault();
      depth += 1;
      zone.classList.add("is-dropping");
    });

    zone.addEventListener("dragover", function (event) {
      if (hasFiles(event)) event.preventDefault();
    });

    zone.addEventListener("dragleave", function () {
      depth = Math.max(0, depth - 1);
      if (depth === 0) zone.classList.remove("is-dropping");
    });

    zone.addEventListener("drop", function (event) {
      if (!hasFiles(event)) return;
      event.preventDefault();
      depth = 0;
      zone.classList.remove("is-dropping");
      uploadFiles(event.dataTransfer.files);
    });

    /* Pasting a screenshot straight into the composer. Only files are taken;
       pasted text must still behave as text. */
    document.addEventListener("paste", function (event) {
      var composer = event.target.closest("[data-composer-input]");
      if (!composer || !event.clipboardData) return;
      var files = Array.prototype.filter.call(
        event.clipboardData.files || [], function (f) { return f && f.size; }
      );
      if (!files.length) return;
      event.preventDefault();
      uploadFiles(files);
    });
  }

  /* --- Installing as an app ----------------------------------------------
     Chromium fires beforeinstallprompt when it decides the app is installable
     and lets the page defer the prompt. The event is the only handle on it, so
     it is kept; there is no way to ask later whether one is available.

     Nothing appears unless the browser offers it. Firefox and desktop Safari
     never fire the event, and there is no useful button to show in their
     place -- an "Install" that does nothing is worse than none. */
  var installPrompt = null;

  function revealInstall(show) {
    document.querySelectorAll("[data-install-app]").forEach(function (el) {
      el.hidden = !show;
    });
  }

  function promptInstall() {
    if (!installPrompt) return;
    installPrompt.prompt();
    installPrompt.userChoice.then(function () {
      // A prompt is single-use, accepted or dismissed.
      installPrompt = null;
      revealInstall(false);
    });
  }

  window.addEventListener("beforeinstallprompt", function (event) {
    event.preventDefault();
    installPrompt = event;
    revealInstall(true);
  });

  window.addEventListener("appinstalled", function () {
    installPrompt = null;
    revealInstall(false);
  });

  /* --- Panels ------------------------------------------------------------- */
  /* A panel can be opened or closed by more than one control -- the button in
     the topbar and the panel's own Close -- and it can now also be closed by
     something nobody clicked, because two panels sharing the right-hand side
     of the screen must not both be open. So the state is applied to the panel
     and then *every* toggle pointing at it is brought in line. Setting
     aria-expanded on the clicked button alone left the other one lying. */
  function syncToggles(selector, open) {
    var toggles = document.querySelectorAll('[data-toggle="' + selector + '"]');
    for (var i = 0; i < toggles.length; i++) {
      toggles[i].setAttribute("aria-expanded", open ? "true" : "false");
      toggles[i].classList.toggle("is-active", open);
    }
  }

  function setPanel(selector, open, group) {
    var panel = document.querySelector(selector);
    if (!panel) return;

    /* One at a time down the right-hand side. Not only a narrow-screen
       concern: a 1280px window with the sidebar, the inspector and the
       terminal all open leaves the conversation about seventy pixels wide. */
    if (open && group) {
      var others = document.querySelectorAll('[data-toggle-group="' + group + '"]');
      for (var i = 0; i < others.length; i++) {
        var other = others[i].dataset.toggle;
        if (other && other !== selector) setPanel(other, false);
      }
    }

    panel.toggleAttribute("hidden", !open);
    syncToggles(selector, open);
    /* What a panel needs to know it is visible. The terminal listens for this:
       xterm cannot measure itself inside a hidden element, so it has to be
       told rather than left to discover. */
    panel.dispatchEvent(
      new CustomEvent("lembas:toggle", { bubbles: true, detail: { open: open } })
    );
  }

  /* --- Dragging a panel wider ---------------------------------------------
     Generic rather than terminal-specific: the inspector and the sidebar want
     the same handle, and a second copy of this is how two panels end up
     resizing differently.

     The width lands on a CSS variable on <html> rather than on the panel, so
     the ≤64rem overlay rule -- which clamps it with min() -- keeps working
     without knowing anything about dragging. Persisted the way the theme is:
     localStorage for this tab, best-effort POST for the next device. */
  var RESIZE_KEY = "lembas-panel-widths";

  function storedWidths() {
    try {
      return JSON.parse(localStorage.getItem(RESIZE_KEY) || "{}") || {};
    } catch (e) {
      return {};
    }
  }

  function applyWidths() {
    var widths = storedWidths();
    Object.keys(widths).forEach(function (name) {
      document.documentElement.style.setProperty(name, widths[name] + "px");
    });
  }

  function rememberWidth(name, pixels) {
    var widths = storedWidths();
    widths[name] = Math.round(pixels);
    try {
      localStorage.setItem(RESIZE_KEY, JSON.stringify(widths));
    } catch (e) { /* private mode */ }
    if (document.body.dataset.authenticated === "true") {
      fetch("/api/preferences/layout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(widths)
      }).catch(function () { /* already applied locally */ });
    }
  }

  function setupResize() {
    document.addEventListener("pointerdown", function (event) {
      var handle = event.target.closest("[data-resize]");
      if (!handle || event.button !== 0) return;
      var panel = handle.closest("[data-resize-target]") || handle.parentElement;
      if (!panel) return;

      var name = handle.dataset.resize;
      var min = parseFloat(handle.dataset.resizeMin || "320");
      var startX = event.clientX;
      var startWidth = panel.getBoundingClientRect().width;
      var frame = null;
      var pending = startWidth;

      event.preventDefault();
      handle.setPointerCapture(event.pointerId);
      document.body.classList.add("is-resizing");

      function move(moveEvent) {
        /* The handle is on the panel's *left* edge and the panel is on the
           right of the shell, so dragging left makes it wider. */
        var max = Math.max(min, window.innerWidth - 360);
        pending = Math.min(Math.max(startWidth - (moveEvent.clientX - startX), min), max);
        /* Coalesced to a frame: the ResizeObserver on the panel calls xterm's
           fit() and sends a resize frame up the socket, and doing that once
           per pointermove is a frame per pixel of drag. */
        if (frame) return;
        frame = requestAnimationFrame(function () {
          frame = null;
          document.documentElement.style.setProperty(name, pending + "px");
        });
      }

      function stop() {
        handle.removeEventListener("pointermove", move);
        handle.removeEventListener("pointerup", stop);
        handle.removeEventListener("pointercancel", stop);
        document.body.classList.remove("is-resizing");
        if (frame) cancelAnimationFrame(frame);
        document.documentElement.style.setProperty(name, pending + "px");
        rememberWidth(name, pending);
      }

      handle.addEventListener("pointermove", move);
      handle.addEventListener("pointerup", stop);
      handle.addEventListener("pointercancel", stop);
    });

    /* A keyboard has to be able to do this too, or the panel is only resizable
       with a mouse and the handle is a focus trap that does nothing. */
    document.addEventListener("keydown", function (event) {
      var handle = event.target.closest("[data-resize]");
      if (!handle) return;
      var step = event.key === "ArrowLeft" ? 32 : event.key === "ArrowRight" ? -32 : 0;
      if (!step) return;
      event.preventDefault();
      var panel = handle.closest("[data-resize-target]") || handle.parentElement;
      var name = handle.dataset.resize;
      var min = parseFloat(handle.dataset.resizeMin || "320");
      var max = Math.max(min, window.innerWidth - 360);
      var width = Math.min(Math.max(panel.getBoundingClientRect().width + step, min), max);
      document.documentElement.style.setProperty(name, width + "px");
      rememberWidth(name, width);
    });
  }

  window.lembas = {
    setPanel: setPanel,
    applyTheme: applyTheme,
    toggleTheme: toggleTheme,
    copyText: copyText,
    scrollThread: scrollThread,
    autosize: autosize,
    uploadFiles: uploadFiles,
    chooseDirectory: chooseDirectory,
    chooseFile: chooseFile,
    promptInstall: promptInstall
  };

  /* --- Wiring ------------------------------------------------------------ */
  document.addEventListener("click", function (event) {
    var toggle = event.target.closest("[data-theme-toggle]");
    if (toggle) {
      event.preventDefault();
      toggleTheme();
      return;
    }

    var copy = event.target.closest("[data-copy]");
    if (copy) {
      event.preventDefault();
      var source = document.getElementById(copy.dataset.copy);
      if (source) copyText(source.textContent.trim(), copy);
      return;
    }

    /* A suggestion card sends its prompt. One click, one reply -- filling the
       box and waiting for Enter makes the card a form to review rather than a
       thing to press. The built-in prompts are written to work sent cold: each
       asks for what it needs, so the answer is a question back rather than a
       guess at material nobody has given yet. */
    var suggestion = event.target.closest("[data-suggestion]");
    if (suggestion) {
      event.preventDefault();
      var input = document.querySelector("[data-composer-input]");
      if (!input) return;
      input.value = suggestion.dataset.suggestion;
      autosize(input);
      if (window.lembas.paintComposer) window.lembas.paintComposer();
      var form = input.closest("form");
      /* requestSubmit, not submit(): it fires the submit event, which is what
         htmx is listening for. Same call the Enter key makes. */
      if (form) form.requestSubmit();
      return;
    }

    /* Show/hide a panel by selector, so templates do not each carry their own
       inline toggle script. */
    var toggle = event.target.closest("[data-toggle]");
    if (toggle) {
      event.preventDefault();
      var panel = document.querySelector(toggle.dataset.toggle);
      if (!panel) return;
      setPanel(toggle.dataset.toggle, panel.hasAttribute("hidden"), toggle.dataset.toggleGroup);
    }
  });

  document.addEventListener("input", function (event) {
    if (event.target.matches("[data-autosize]")) autosize(event.target);
  });

  /* A "select all" box driving every checkbox inside a container. Scoped to a
     selector rather than the whole page, so a list can carry more than one. */
  document.addEventListener("change", function (event) {
    var master = event.target.closest("[data-select-all]");
    if (!master) return;
    var scope = document.querySelector(master.dataset.selectAll);
    if (!scope) return;
    scope.querySelectorAll('input[type="checkbox"]').forEach(function (box) {
      box.checked = master.checked;
    });
  });

  /* Enter sends, Shift+Enter inserts a newline -- the convention every chat
     application uses. Left alone on touch devices, where there is no easy
     Shift and Enter should mean "new line". */
  document.addEventListener("keydown", function (event) {
    if (event.key !== "Enter" || event.shiftKey) return;
    /* The Enter that commits an IME composition is not the Enter that sends.
       Typing Japanese or Chinese, every accepted candidate would otherwise
       post the half-written message. */
    if (event.isComposing || event.keyCode === 229) return;
    var composer = event.target.closest("[data-composer-input]");
    if (!composer) return;
    if (window.matchMedia("(pointer: coarse)").matches) return;
    event.preventDefault();
    var form = composer.closest("form");
    if (form && composer.value.trim()) form.requestSubmit();
  });

  document.addEventListener("DOMContentLoaded", function () {
    document.querySelectorAll("[data-autosize]").forEach(autosize);
    scrollThread(true);
    applyTheme(currentTheme());
    setupDropzone();
    setupResize();
  });

  /* Before first paint rather than on DOMContentLoaded, so a panel that was
     dragged wider does not open at its default and jump. */
  applyWidths();

  /* After any htmx swap: re-measure the composer and follow new content. */
  document.body.addEventListener("htmx:afterSwap", function () {
    document.querySelectorAll("[data-autosize]").forEach(autosize);
    scrollThread(false);
  });

  /* --- The transcript tail -----------------------------------------------
     A reply can begin without a request from this page: a background job
     finishing wakes the chat server-side. There is no chat-level channel to
     hear about it on -- the only stream is per-message, and it is opened by a
     bubble this page has not got. So `#thread-tail` polls, and this is where it
     is told what the page already holds.

     The cursor is read from the DOM rather than from a variable rendered into
     the page, because the DOM is the honest answer to that question. Every path
     that appends a bubble moves it -- the composer's own POST, the `done`
     frame's out-of-band swaps, the last poll -- and a variable would have to be
     updated by each of them, correctly, forever.

     `htmx:configRequest` and not `hx-vals="js:…"`: two of the three things here
     are *cancellations*, which `hx-vals` cannot express, and splitting the read
     from the cancellations would put one decision in two files. (It is also the
     only string htmx would ever be handed to evaluate in this project, and it
     would die silently under a CSP.) */
  document.body.addEventListener("htmx:configRequest", function (event) {
    var elt = (event.detail && event.detail.elt) || event.target;
    if (!elt || elt.id !== "thread-tail") return;

    var thread = document.getElementById("thread");
    if (!thread) return event.preventDefault();

    /* Quiet while this page is following a reply. That reply delivers its own
       bubbles through the `done` frame, which is the only channel that can get
       the *order* right -- and it is the one window in which the transcript's
       order moves underneath us, since `_inject` restamps the placeholder to
       sort after a prompt taken into it. Asking during it is how a page appends
       a bubble it already has.

       Exact rather than approximate: a page holding an incomplete assistant
       bubble always carries this attribute, which is the state machine
       `_message.html` documents. */
    if (thread.querySelector("[sse-connect]")) return event.preventDefault();

    /* Deliberately not `article.msg:last-of-type`. That is per-parent, and
       `querySelector` returns the first match in document order -- so on a
       compacted chat it answers with the last article inside
       `<details class="compacted">` rather than the newest message. The last of
       everything matching is what "the last bubble this page holds" means. */
    var articles = thread.querySelectorAll("article.msg");
    var last = articles.length ? articles[articles.length - 1] : null;
    if (!last || last.id.indexOf("msg-") !== 0) return event.preventDefault();

    event.detail.parameters.after = last.id.slice(4);
  });

  /* Whatever the reason -- the composer's POST committing between this request
     going out and its answer coming back, a `done` frame landing first -- if any
     bubble in the answer is already on the page then the page has moved on since
     the question was asked, and swapping would duplicate it. A duplicate here is
     not cosmetic: it would carry a second `sse-connect` for one message.

     This is the race `hx-sync` cannot reach, since the two requests come from
     different elements. The whole answer is dropped rather than filtered: the
     next poll is five seconds away and recomputes its cursor from a DOM that has
     settled, which is a correct page one tick late instead of a wrong one now. */
  document.body.addEventListener("htmx:beforeSwap", function (event) {
    var elt = (event.detail && event.detail.elt) || event.target;
    if (!elt || elt.id !== "thread-tail" || !event.detail) return;

    var seen = /\bid="(msg-[^"]+)"/g;
    var body = event.detail.serverResponse || "";
    var match;
    while ((match = seen.exec(body)) !== null) {
      if (document.getElementById(match[1])) {
        event.detail.shouldSwap = false;
        return;
      }
    }
  });

  /* Tokens arriving over SSE are appended outside the normal swap cycle.

     Narrowed to frames that land in the thread. `metrics`, `status`, `ask` and
     `canvas` all arrive on this event too, so the unconditional version fired
     up to seven times per version bump -- most of them for content that changes
     no height at all. */
  document.body.addEventListener("htmx:sseMessage", function (event) {
    var target = event.target;
    if (target && target.closest && !target.closest("#thread-scroll")) return;
    scrollThread(false);
  });
})();
