/* Typing affordances in the composer: `@` to attach something by name, and (from the commands section below) `/` to run something instead of sending. Both are the same shape -- a token at the caret opens a menu, the menu filters as you type, and choosing replaces the token -- so they share one menu and one keyboard handler rather than fighting over the composer. Three things here are not obvious. The keydown listener is registered with `capture: true`. app.js already has a document-level Enter handler that submits the form, and listeners on the same element in the same phase fire in registration order -- app.js loads first, so a bubble-phase listener here would never get to say "that Enter chose a menu item, it did not send the message". Nothing is inserted into the composer as HTML. Every name in the menu came off somebody's filesystem or out of their library. A chip is a chip. Choosing a file posts to a route that returns the same attachment chip an upload returns, so the composer learns nothing new and the remove button, the hidden file_ids input and `claim()` on send all work already. */ (function () { "use strict"; var menu = null; var list = null; var open = false; var kind = ""; var pending = null; var active = -1; /* commands.js loads first and defines these. Guarded anyway so that a page which does not carry it -- or one where it failed to parse -- still gets `@`, and a message beginning with a slash is simply sent. */ function commands() { return window.lembasCommands || { list: function () { return document.createElement("div"); }, find: function () { return null; }, run: function () {} }; } function commandList(query) { return commands().list(query); } function commandIn(value) { return commands().find(value); } function runCommand(name, rest) { commands().run(name, rest); } /* --- Where we are ------------------------------------------------------- */ function composer() { return document.querySelector("[data-composer-input]"); } function context() { var box = document.querySelector(".composer"); var picker = document.querySelector('select[name="ssh_profile_id"]'); var dir = document.querySelector("[data-dir-value]"); return { chatId: (box && box.dataset.chatId) || "", /* A chat under way carries its connection on the composer; a new one is still choosing it, so the select and the hidden field are the truth. */ profileId: picker ? picker.value : (box && box.dataset.profileId) || "", projectDir: dir ? dir.value : (box && box.dataset.projectDir) || "" }; } /* The token being typed, or null. `@` is claimed anywhere it follows whitespace, so `see @src/main.py` works mid-sentence, but not inside an email address -- `a@b` is not a mention and treating it as one would open a menu every time somebody typed one. */ function tokenAt(input) { var value = input.value; var caret = input.selectionStart; if (caret !== input.selectionEnd) return null; var before = value.slice(0, caret); var at = before.lastIndexOf("@"); if (at !== -1 && (at === 0 || /\s/.test(before[at - 1]))) { var query = before.slice(at + 1); if (!/\s/.test(query)) return { kind: "@", query: query, start: at, end: caret }; } /* A slash command is only ever the first thing in the box. Anywhere else a slash is a path, a date or a fraction. */ if (before[0] === "/" && before[1] !== "/") { var word = before.slice(1); if (!/\s/.test(word) && caret === value.length) { return { kind: "/", query: word, start: 0, end: caret }; } } return null; } /* --- The menu ----------------------------------------------------------- */ function build() { if (menu) return; var host = document.querySelector(".composer__inner"); if (!host) return; menu = document.createElement("div"); menu.className = "composer-menu"; menu.setAttribute("role", "listbox"); menu.hidden = true; list = document.createElement("div"); menu.appendChild(list); host.insertBefore(menu, host.firstChild); menu.addEventListener("mousedown", function (event) { /* Before the composer loses focus, or the caret position the choice is about to be written at is already gone. */ event.preventDefault(); }); menu.addEventListener("click", function (event) { var option = event.target.closest("[data-mention-token], [data-command]"); if (option) choose(option); }); } function show() { build(); if (!menu) return; menu.hidden = false; open = true; } function hide() { if (!menu) return; menu.hidden = true; open = false; kind = ""; active = -1; } function options() { return menu ? Array.prototype.slice.call( menu.querySelectorAll("[data-mention-token], [data-command]") ) : []; } function highlight(index) { var all = options(); if (!all.length) return; active = (index + all.length) % all.length; all.forEach(function (option, position) { option.classList.toggle("is-selected", position === active); }); if (all[active].scrollIntoView) all[active].scrollIntoView({ block: "nearest" }); } /* --- Filling it --------------------------------------------------------- */ function loadMentions(query) { var where = context(); var url = "/api/files/mention-picker?q=" + encodeURIComponent(query) + "&chat_id=" + encodeURIComponent(where.chatId) + "&profile_id=" + encodeURIComponent(where.profileId) + "&project_dir=" + encodeURIComponent(where.projectDir); fetch(url, { credentials: "same-origin" }) .then(function (response) { return response.text(); }) .then(function (html) { if (kind !== "@") return; list.innerHTML = html; show(); highlight(0); }) .catch(function () { hide(); }); } function refresh() { var input = composer(); if (!input) return; var token = tokenAt(input); if (!token) return hide(); kind = token.kind; if (token.kind === "/") { list.innerHTML = ""; list.appendChild(commandList(token.query)); show(); highlight(0); return; } clearTimeout(pending); pending = setTimeout(function () { loadMentions(token.query); }, 150); } /* --- Choosing ----------------------------------------------------------- */ function replaceToken(text) { var input = composer(); var token = tokenAt(input); if (!input || !token) return; var head = input.value.slice(0, token.start); var tail = input.value.slice(token.end); var written = (token.kind === "@" ? "@" : "/") + text + " "; input.value = head + written + tail; var caret = head.length + written.length; input.setSelectionRange(caret, caret); if (window.lembas && window.lembas.autosize) window.lembas.autosize(input); input.focus(); } function choose(option) { if (option.dataset.command) { hide(); runCommand(option.dataset.command, ""); return; } var where = context(); /* The reference stays in the sentence being written *and* the contents come along as a chip. The first is what makes "change the thing in @main.py" read as a sentence; the second is what stops a small model having to spend a round fetching it. */ replaceToken(option.dataset.mentionToken); hide(); var body = new FormData(); body.append("chat_id", where.chatId); if (option.dataset.mentionFile) { body.append("profile_id", where.profileId); body.append("path", option.dataset.mentionFile); attach("/api/files/from-project", body); } else if (option.dataset.mentionKnowledge) { body.append("document_id", option.dataset.mentionKnowledge); attach("/api/files/from-knowledge", body); } } function attach(url, body) { var target = document.getElementById("attachments"); if (!target) return; 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 and inert until announced. if (window.htmx) window.htmx.process(target.lastElementChild); }) .catch(function () { if (window.lembas) window.lembas.notify("Could not attach that.", { kind: "error" }); }); } /* --- Keys --------------------------------------------------------------- */ document.addEventListener( "keydown", function (event) { var input = event.target.closest && event.target.closest("[data-composer-input]"); if (!input) return; if (open) { if (event.key === "Escape") { event.stopPropagation(); event.preventDefault(); return hide(); } if (event.key === "ArrowDown") { event.preventDefault(); return highlight(active + 1); } if (event.key === "ArrowUp") { event.preventDefault(); return highlight(active - 1); } if (event.key === "Enter" || event.key === "Tab") { var all = options(); if (all.length && active >= 0) { /* Capture phase, so app.js's Enter-to-send never sees this one. Without stopping it the message would be sent *and* the menu item chosen. */ event.preventDefault(); event.stopPropagation(); choose(all[active]); return; } } } if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { /* Not a menu key: a command typed in full and submitted. Handled here rather than on submit so the form is never posted at all -- a command is not a message and must not become one if it is unknown. */ var command = commandIn(input.value); if (command) { event.preventDefault(); event.stopPropagation(); input.value = ""; if (window.lembas && window.lembas.autosize) window.lembas.autosize(input); runCommand(command.name, command.rest); } } }, true ); document.addEventListener("input", function (event) { if (event.target.closest("[data-composer-input]")) refresh(); }); document.addEventListener("click", function (event) { if (!event.target.closest(".composer-menu") && !event.target.closest("[data-composer-input]")) hide(); }); /* Opening the menu from a button, for anyone who would rather press than type. Inserts the character and lets the normal path take over. */ document.addEventListener("click", function (event) { var button = event.target.closest("[data-mention-open]"); if (!button) return; event.preventDefault(); var input = composer(); if (!input) return; input.focus(); var caret = input.selectionStart; var lead = caret && !/\s/.test(input.value[caret - 1]) ? " @" : "@"; input.setRangeText(lead, caret, caret, "end"); refresh(); }); window.lembas = window.lembas || {}; window.lembas.closeComposerMenu = hide; })();