674 lines
28 KiB
JavaScript
674 lines
28 KiB
JavaScript
/*
|
|
* enkly — app.js
|
|
*
|
|
* A small, dependency-free enhancement layer. It is served same-origin
|
|
* (the server's Content-Security-Policy is script-src 'self'), and none of
|
|
* it uses eval or inline handlers. Everything here is optional except the
|
|
* "clear the composer after sending" behaviour, which replaces what used to
|
|
* be an hx-on attribute (htmx evaluates hx-on with eval(), which the CSP
|
|
* blocks).
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
var chat = document.getElementById("chat-window");
|
|
var input = document.getElementById("composer-input");
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Auto-scroll: follow the stream unless the user scrolled up to read.
|
|
* ------------------------------------------------------------------ */
|
|
var stickToBottom = true;
|
|
|
|
function nearBottom() {
|
|
return chat.scrollHeight - chat.scrollTop - chat.clientHeight < 120;
|
|
}
|
|
|
|
function scrollToBottom(smooth) {
|
|
chat.scrollTo({ top: chat.scrollHeight, behavior: smooth ? "smooth" : "auto" });
|
|
}
|
|
|
|
if (chat) {
|
|
scrollToBottom(false);
|
|
chat.addEventListener("scroll", function () { stickToBottom = nearBottom(); }, { passive: true });
|
|
new MutationObserver(function () {
|
|
if (stickToBottom) scrollToBottom(false);
|
|
}).observe(chat, { childList: true, subtree: true, characterData: true });
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Composer: auto-resize, Enter to send, clear after a successful send.
|
|
* ------------------------------------------------------------------ */
|
|
function resizeInput() {
|
|
if (!input) return;
|
|
input.style.height = "auto";
|
|
input.style.height = Math.min(input.scrollHeight, 220) + "px";
|
|
}
|
|
|
|
// A send is valid once there's either typed text or at least one
|
|
// attachment that isn't still mid-upload (see "Composer attachments"
|
|
// below) — an attachment-only message is fine, same as Claude Code.
|
|
function canSend() {
|
|
if (input && input.value.trim() !== "") return true;
|
|
return attachmentsReadyCount() > 0;
|
|
}
|
|
|
|
if (input) {
|
|
input.addEventListener("input", resizeInput);
|
|
input.addEventListener("keydown", function (e) {
|
|
// Enter sends; Shift+Enter inserts a newline; ignore Enter while an
|
|
// IME composition (CJK input, etc.) is confirming a candidate.
|
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
|
|
e.preventDefault();
|
|
if (canSend() && input.form) input.form.requestSubmit();
|
|
}
|
|
});
|
|
input.focus();
|
|
// Covers the case where the browser restores a typed-but-unsent value
|
|
// on reload (bfcache / "preserve form on back"), so the box already
|
|
// matches its content's height instead of starting at one line.
|
|
resizeInput();
|
|
}
|
|
|
|
// Block the actual request (rather than just the Enter shortcut above)
|
|
// if an attachment is still uploading when Send is clicked directly, or
|
|
// let it through with nothing at all so the server's own "Type a
|
|
// message first." validation handles that case exactly as before.
|
|
document.body.addEventListener("htmx:beforeRequest", function (e) {
|
|
var elt = e.detail && e.detail.elt;
|
|
if (!elt || !elt.classList || !elt.classList.contains("composer-form")) return;
|
|
if (attachmentsPendingCount() > 0) {
|
|
e.preventDefault();
|
|
toast("Still uploading \u2014 one moment.", true);
|
|
}
|
|
});
|
|
|
|
// htmx fires this after the /chat request completes. Clear the box and
|
|
// re-enable following the stream (sending a message means "I'm here").
|
|
document.body.addEventListener("htmx:afterRequest", function (e) {
|
|
var elt = e.detail && e.detail.elt;
|
|
if (elt && elt.classList && elt.classList.contains("composer-form") && e.detail.successful) {
|
|
elt.reset();
|
|
resizeInput();
|
|
clearAttachments();
|
|
stickToBottom = true;
|
|
if (chat) scrollToBottom(true);
|
|
if (input) input.focus();
|
|
}
|
|
});
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Copy button on code blocks (event delegation: works for blocks that
|
|
* arrive later over SSE).
|
|
* ------------------------------------------------------------------ */
|
|
function copyText(text) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
return navigator.clipboard.writeText(text);
|
|
}
|
|
return new Promise(function (resolve, reject) {
|
|
var ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.setAttribute("readonly", "");
|
|
ta.style.position = "fixed";
|
|
ta.style.opacity = "0";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try {
|
|
document.execCommand("copy") ? resolve() : reject(new Error("copy failed"));
|
|
} catch (err) {
|
|
reject(err);
|
|
} finally {
|
|
document.body.removeChild(ta);
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener("click", function (e) {
|
|
var btn = e.target.closest && e.target.closest(".copy-btn");
|
|
if (!btn) return;
|
|
var block = btn.closest(".code-block");
|
|
var code = block && block.querySelector("pre code");
|
|
if (!code) return;
|
|
copyText(code.innerText).then(function () {
|
|
btn.textContent = "Copied";
|
|
}, function () {
|
|
btn.textContent = "Press Ctrl+C";
|
|
}).then(function () {
|
|
setTimeout(function () { btn.textContent = "Copy"; }, 1500);
|
|
});
|
|
});
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Snippet download cards: build the file in the browser from the code
|
|
* block the card refers to (the Nth .code-block in the same reply), so
|
|
* no server round-trip or temp file is needed. File cards are plain
|
|
* links and need no JS.
|
|
* ------------------------------------------------------------------ */
|
|
// SNIPPET-DOWNLOAD:BEGIN (scripts/test_snippet_download.mjs extracts this block)
|
|
function downloadSnippet(btn) {
|
|
var bubble = btn.closest(".bubble");
|
|
var blocks = bubble ? bubble.querySelectorAll(".code-block pre code") : [];
|
|
var idx = parseInt(btn.getAttribute("data-snippet-index"), 10);
|
|
var code = isNaN(idx) ? null : blocks[idx];
|
|
if (!code) return false;
|
|
// textContent (not innerText) so the file is exactly the source text,
|
|
// regardless of the highlight <span>s or CSS.
|
|
var blob = new Blob([code.textContent], { type: "text/plain;charset=utf-8" });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = btn.getAttribute("data-filename") || "snippet.txt";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
return true;
|
|
}
|
|
|
|
document.addEventListener("click", function (e) {
|
|
var btn = e.target.closest && e.target.closest(".snippet-download");
|
|
if (!btn) return;
|
|
var ok = downloadSnippet(btn);
|
|
var label = btn.getAttribute("data-label") || btn.textContent;
|
|
btn.setAttribute("data-label", label);
|
|
btn.textContent = ok ? "Downloaded" : "Unavailable";
|
|
setTimeout(function () { btn.textContent = label; }, 1500);
|
|
});
|
|
// SNIPPET-DOWNLOAD:END
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Stop button: cancel via fetch instead of navigating away from the
|
|
* live stream. (Without JS the form still works — it POSTs and the
|
|
* server redirects back to the conversation.)
|
|
* ------------------------------------------------------------------ */
|
|
document.addEventListener("submit", function (e) {
|
|
var form = e.target;
|
|
if (!form.classList || !form.classList.contains("stop-form")) return;
|
|
e.preventDefault();
|
|
var btn = form.querySelector("button");
|
|
if (btn) btn.disabled = true;
|
|
fetch(form.action, {
|
|
method: "POST",
|
|
headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams(new FormData(form)),
|
|
credentials: "same-origin",
|
|
}).catch(function () {
|
|
if (btn) btn.disabled = false;
|
|
});
|
|
});
|
|
/* ------------------------------------------------------------------
|
|
* Thinking indicator: time it, then collapse it once the reply starts.
|
|
*
|
|
* The card is rendered open by default (see chat_response.html) so
|
|
* reasoning is visible live, the same way it streams in before the
|
|
* answer on claude.ai — no JS needed for that part. What JS adds is
|
|
* purely cosmetic: once the reply actually begins, collapse the card
|
|
* into a "Thought for Ns" summary instead of leaving it expanded
|
|
* under the finished answer. htmx-ext-sse fires a bubbling
|
|
* "htmx:sseBeforeMessage" for every named SSE event it receives, with
|
|
* detail.type set to that event's name ("thinking", "token", "done",
|
|
* "tool") and evt.target set to whichever sse-swap element matched —
|
|
* we don't touch the actual content swap here, htmx-ext-sse already
|
|
* does that; this only reads the event to time things.
|
|
* ------------------------------------------------------------------ */
|
|
var thinkingStartedAt = new WeakMap();
|
|
|
|
document.body.addEventListener("htmx:sseBeforeMessage", function (e) {
|
|
var type = e.detail && e.detail.type;
|
|
if (type !== "thinking" && type !== "token" && type !== "done") return;
|
|
|
|
var row = e.target.closest && e.target.closest(".message-row");
|
|
var card = row && row.querySelector(".thinking-card");
|
|
if (!card || card.dataset.settled) return;
|
|
|
|
if (type === "thinking") {
|
|
if (!thinkingStartedAt.has(card)) thinkingStartedAt.set(card, Date.now());
|
|
return;
|
|
}
|
|
|
|
// First "token" or "done" seen for this row: settle the card once,
|
|
// whether or not it ever actually thought out loud.
|
|
card.dataset.settled = "1";
|
|
if (thinkingStartedAt.has(card)) {
|
|
var seconds = Math.max(1, Math.round((Date.now() - thinkingStartedAt.get(card)) / 1000));
|
|
var label = card.querySelector(".thinking-elapsed");
|
|
if (label) label.textContent = "\u00B7 Thought for " + seconds + "s";
|
|
card.open = false;
|
|
}
|
|
});
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Composer attachments — "Add files or photos", strongly inspired by
|
|
* Claude Code's chat input: click the paperclip, drop a file anywhere
|
|
* on the page (dropping right on the message box highlights it, like
|
|
* claude.ai), or paste a screenshot, and it becomes a chip in the tray
|
|
* above the textarea. Each chip uploads in the background the moment
|
|
* it appears — one fetch per file, to POST /files/attach (see
|
|
* internal/handlers/attachments.go) — so sending the message is just
|
|
* the composer form's normal htmx POST /chat; the uploaded workspace
|
|
* path already rides along as a hidden input (see addHiddenInput
|
|
* below). Remove a chip before sending to drop it from the message;
|
|
* the file stays in the workspace (nothing currently reaps unattached
|
|
* uploads — a known, accepted gap, same spirit as the ones called out
|
|
* in ARCHITECTURE.md).
|
|
* ------------------------------------------------------------------ */
|
|
var tray = document.getElementById("composer-attachments");
|
|
var attachFileInput = document.getElementById("composer-file-input");
|
|
var attachBtn = document.getElementById("attach-btn");
|
|
var composerForm = input && input.closest("form");
|
|
var attachments = Object.create(null); // clientId -> { status, path, controller }
|
|
var attachSeq = 0;
|
|
var maxAttachments = 8; // mirrors maxChatAttachments server-side
|
|
|
|
function toast(msg, isError) {
|
|
var el = document.createElement("div");
|
|
el.className = "drop-toast" + (isError ? " is-error" : "");
|
|
el.setAttribute("role", "status");
|
|
el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
setTimeout(function () { el.classList.add("is-leaving"); }, 3200);
|
|
setTimeout(function () { el.remove(); }, 3700);
|
|
}
|
|
|
|
function attachmentsReadyCount() {
|
|
var n = 0;
|
|
for (var id in attachments) if (attachments[id].status === "done") n++;
|
|
return n;
|
|
}
|
|
function attachmentsPendingCount() {
|
|
var n = 0;
|
|
for (var id in attachments) if (attachments[id].status === "uploading") n++;
|
|
return n;
|
|
}
|
|
function attachmentsCount() {
|
|
return Object.keys(attachments).length;
|
|
}
|
|
|
|
function fileIconSVG() {
|
|
return '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
|
|
'stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/>' +
|
|
'<path d="M14 3v5h5"/></svg>';
|
|
}
|
|
|
|
// Shown for a .zip while it's uploading/extracting, and again on the
|
|
// sent message's chip (see attachments.html) once the server confirms
|
|
// it really was one — see isZipName / markChipAsArchive below.
|
|
function archiveIconSVG() {
|
|
return '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
|
|
'stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
|
|
}
|
|
|
|
function isZipName(name) {
|
|
return /\.zip$/i.test(name || "");
|
|
}
|
|
|
|
// Plain "path" for a regular file; "path|N" for an extracted zip's
|
|
// folder, so cleanAttachmentPaths (internal/handlers/attachments.go)
|
|
// can tell the two apart when the composer form is submitted.
|
|
function addHiddenInput(id, path, entryCount) {
|
|
var el = document.createElement("input");
|
|
el.type = "hidden";
|
|
el.name = "attachments";
|
|
el.value = entryCount ? path + "|" + entryCount : path;
|
|
el.dataset.attachId = id;
|
|
composerForm.appendChild(el);
|
|
}
|
|
|
|
// Swaps a settled chip's icon for the folder glyph and appends the
|
|
// "N files" count once /files/attach confirms the zip was extracted.
|
|
function markChipAsArchive(id, entryCount) {
|
|
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
|
|
if (!chip) return;
|
|
chip.classList.add("is-archive");
|
|
var preview = chip.querySelector(".attach-chip-preview");
|
|
if (preview) preview.innerHTML = archiveIconSVG();
|
|
var name = chip.querySelector(".attach-chip-name");
|
|
if (name) name.textContent = name.textContent + " \u00B7 " + entryCount + (entryCount === 1 ? " file" : " files");
|
|
}
|
|
|
|
function removeAttachment(id) {
|
|
var rec = attachments[id];
|
|
if (!rec) return;
|
|
if (rec.controller) rec.controller.abort();
|
|
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
|
|
if (chip) chip.remove();
|
|
var hidden = composerForm && composerForm.querySelector('input[data-attach-id="' + id + '"]');
|
|
if (hidden) hidden.remove();
|
|
delete attachments[id];
|
|
}
|
|
|
|
function clearAttachments() {
|
|
for (var id in attachments) removeAttachment(id);
|
|
tray.innerHTML = "";
|
|
}
|
|
|
|
function settleChip(id, ok, label) {
|
|
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
|
|
if (!chip) return;
|
|
chip.classList.remove("is-uploading");
|
|
chip.classList.toggle("is-error", !ok);
|
|
if (!ok) chip.title = label || "Upload failed \u2014 click to remove";
|
|
}
|
|
|
|
function renderChip(id, file) {
|
|
var chip = document.createElement("div");
|
|
chip.className = "attach-chip is-uploading";
|
|
chip.dataset.attachId = id;
|
|
|
|
var preview = document.createElement("div");
|
|
preview.className = "attach-chip-preview";
|
|
if (file.type && file.type.indexOf("image/") === 0 && typeof FileReader === "function") {
|
|
// A data: URL, not blob: — the page's img-src CSP (see
|
|
// internal/handlers/middleware.go) allows 'self' and data: only, so
|
|
// this is the one local-preview mechanism that actually renders.
|
|
var reader = new FileReader();
|
|
reader.onload = function () {
|
|
if (!attachments[id]) return; // chip was removed before this fired
|
|
var img = document.createElement("img");
|
|
img.src = reader.result;
|
|
img.alt = "";
|
|
preview.insertBefore(img, preview.firstChild);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
} else if (isZipName(file.name)) {
|
|
preview.innerHTML = archiveIconSVG();
|
|
} else {
|
|
preview.innerHTML = fileIconSVG();
|
|
}
|
|
var spinner = document.createElement("span");
|
|
spinner.className = "attach-chip-spinner";
|
|
spinner.setAttribute("aria-hidden", "true");
|
|
preview.appendChild(spinner);
|
|
|
|
var name = document.createElement("span");
|
|
name.className = "attach-chip-name";
|
|
name.textContent = file.name;
|
|
|
|
var remove = document.createElement("button");
|
|
remove.type = "button";
|
|
remove.className = "attach-chip-remove";
|
|
remove.setAttribute("aria-label", "Remove " + file.name);
|
|
remove.textContent = "\u00D7";
|
|
remove.addEventListener("click", function (ev) {
|
|
ev.preventDefault();
|
|
removeAttachment(id);
|
|
});
|
|
|
|
chip.appendChild(preview);
|
|
chip.appendChild(name);
|
|
chip.appendChild(remove);
|
|
tray.appendChild(chip);
|
|
}
|
|
|
|
function uploadAttachment(id, file) {
|
|
var rec = attachments[id];
|
|
var controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
if (controller) rec.controller = controller;
|
|
|
|
var body = new FormData();
|
|
body.append("csrf_token", composerForm.querySelector('input[name="csrf_token"]').value);
|
|
body.append("conversation_id", composerForm.querySelector('input[name="conversation_id"]').value);
|
|
body.append("files", file);
|
|
|
|
fetch("/files/attach", {
|
|
method: "POST",
|
|
body: body,
|
|
credentials: "same-origin",
|
|
signal: controller ? controller.signal : undefined,
|
|
})
|
|
.then(function (res) {
|
|
return res.json().then(function (data) { return { ok: res.ok, data: data }; });
|
|
})
|
|
.then(function (result) {
|
|
if (!attachments[id]) return; // removed while in flight
|
|
if (!result.ok) {
|
|
rec.status = "error";
|
|
settleChip(id, false, (result.data && result.data.error) || "Upload failed");
|
|
toast((result.data && result.data.error) || ("Couldn\u2019t attach " + file.name + "."), true);
|
|
return;
|
|
}
|
|
rec.status = "done";
|
|
rec.path = result.data.path;
|
|
addHiddenInput(id, result.data.path, result.data.entryCount);
|
|
if (result.data.isArchive) markChipAsArchive(id, result.data.entryCount);
|
|
settleChip(id, true);
|
|
})
|
|
.catch(function (err) {
|
|
if (!attachments[id] || (err && err.name === "AbortError")) return;
|
|
rec.status = "error";
|
|
settleChip(id, false, "Upload failed");
|
|
toast("Couldn\u2019t attach " + file.name + ".", true);
|
|
});
|
|
}
|
|
|
|
function stageFiles(fileList) {
|
|
if (!tray || !composerForm) return;
|
|
var files = Array.prototype.slice.call(fileList || []);
|
|
if (!files.length) return;
|
|
var room = maxAttachments - attachmentsCount();
|
|
if (room <= 0) {
|
|
toast("Up to " + maxAttachments + " files per message.", true);
|
|
return;
|
|
}
|
|
if (files.length > room) {
|
|
toast("Only the first " + room + " file" + (room === 1 ? "" : "s") + " were added (limit " + maxAttachments + ").", true);
|
|
files = files.slice(0, room);
|
|
}
|
|
files.forEach(function (file) {
|
|
var id = "a" + (++attachSeq);
|
|
attachments[id] = { status: "uploading" };
|
|
renderChip(id, file);
|
|
uploadAttachment(id, file);
|
|
});
|
|
}
|
|
|
|
if (attachBtn && attachFileInput) {
|
|
attachBtn.addEventListener("click", function () { attachFileInput.click(); });
|
|
attachFileInput.addEventListener("change", function () {
|
|
stageFiles(attachFileInput.files);
|
|
attachFileInput.value = ""; // allow re-picking the same file later
|
|
});
|
|
}
|
|
|
|
/* ---- Drag & drop (anywhere on the page) + paste ---- */
|
|
var overlay = null;
|
|
var dragDepth = 0;
|
|
|
|
function overComposer(e) {
|
|
return !!(composerForm && e.target && composerForm.contains(e.target));
|
|
}
|
|
|
|
function hasFiles(e) {
|
|
var t = e.dataTransfer && e.dataTransfer.types;
|
|
if (!t) return false;
|
|
for (var i = 0; i < t.length; i++) if (t[i] === "Files") return true;
|
|
return false;
|
|
}
|
|
|
|
function showOverlay(on) {
|
|
if (!overlay) {
|
|
overlay = document.createElement("div");
|
|
overlay.className = "drop-overlay";
|
|
overlay.setAttribute("aria-hidden", "true");
|
|
overlay.innerHTML = '<div class="drop-overlay-box"><strong>Drop files to attach</strong>' +
|
|
"<span>They\u2019ll be added to your next message</span></div>";
|
|
document.body.appendChild(overlay);
|
|
}
|
|
overlay.classList.toggle("is-visible", on);
|
|
}
|
|
|
|
document.addEventListener("dragenter", function (e) {
|
|
if (!hasFiles(e)) return;
|
|
e.preventDefault();
|
|
dragDepth++;
|
|
showOverlay(true);
|
|
});
|
|
document.addEventListener("dragover", function (e) {
|
|
if (!hasFiles(e)) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
// Hovering the message box: highlight it instead of the page overlay.
|
|
document.body.classList.toggle("drag-composer", overComposer(e));
|
|
});
|
|
document.addEventListener("dragleave", function (e) {
|
|
if (!hasFiles(e)) return;
|
|
dragDepth = Math.max(0, dragDepth - 1);
|
|
if (dragDepth === 0) {
|
|
showOverlay(false);
|
|
document.body.classList.remove("drag-composer");
|
|
}
|
|
});
|
|
document.addEventListener("drop", function (e) {
|
|
if (!hasFiles(e)) return;
|
|
e.preventDefault();
|
|
dragDepth = 0;
|
|
showOverlay(false);
|
|
document.body.classList.remove("drag-composer");
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length) stageFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
// Pasting a file (e.g. a screenshot) into the message box attaches it too.
|
|
if (input) {
|
|
input.addEventListener("paste", function (e) {
|
|
var files = e.clipboardData && e.clipboardData.files;
|
|
if (!files || !files.length) return;
|
|
e.preventDefault();
|
|
stageFiles(files);
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Send-button state: dim "Send" while there is nothing to send. Purely
|
|
* visual (data-empty on the form); the server still validates.
|
|
* ------------------------------------------------------------------ */
|
|
function syncSendState() {
|
|
if (composerForm) composerForm.setAttribute("data-empty", canSend() ? "false" : "true");
|
|
}
|
|
if (composerForm && input) {
|
|
input.addEventListener("input", syncSendState);
|
|
new MutationObserver(syncSendState).observe(composerForm, { childList: true });
|
|
if (tray) new MutationObserver(syncSendState).observe(tray, { childList: true });
|
|
document.body.addEventListener("htmx:afterRequest", function () { setTimeout(syncSendState, 0); });
|
|
syncSendState();
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Empty-state starters and "More to explore" chips. A chip with
|
|
* data-send="true" is self-contained and submits straight away; the
|
|
* rest drop their text into the composer for the person to finish.
|
|
* ------------------------------------------------------------------ */
|
|
document.addEventListener("click", function (e) {
|
|
var el = e.target.closest && e.target.closest("[data-prompt]");
|
|
if (!el || !input) return;
|
|
input.value = el.getAttribute("data-prompt");
|
|
resizeInput();
|
|
syncSendState();
|
|
input.focus();
|
|
input.setSelectionRange(input.value.length, input.value.length);
|
|
if (el.getAttribute("data-send") === "true" && composerForm) composerForm.requestSubmit();
|
|
});
|
|
|
|
/* ------------------------------------------------------------------
|
|
* Command palette (Cmd/Ctrl+K or the top-bar search): jump to a
|
|
* conversation or run a quick action. Everything it lists is read from
|
|
* the sidebar DOM, so there is no new endpoint and it works offline.
|
|
* ------------------------------------------------------------------ */
|
|
(function palette() {
|
|
var trigger = document.getElementById("cmd-trigger");
|
|
var overlay, field, list, items = [], selected = 0;
|
|
|
|
function collect() {
|
|
var out = [];
|
|
var links = document.querySelectorAll(".conversation-link");
|
|
for (var i = 0; i < links.length; i++) {
|
|
out.push({ group: "Conversations", title: links[i].textContent.trim(), href: links[i].getAttribute("href"), glyph: "--i-file" });
|
|
}
|
|
out.push({ group: "Actions", title: "New chat", hint: "Start fresh", glyph: "--i-spark", run: function () {
|
|
var f = document.querySelector(".new-chat-form"); if (f) f.submit();
|
|
} });
|
|
out.push({ group: "Actions", title: "Manage subscription", href: "/subscription", glyph: "--i-cog" });
|
|
out.push({ group: "Actions", title: "Attach files", hint: "Add to next message", glyph: "--i-folder", run: function () {
|
|
if (attachBtn) attachBtn.click();
|
|
} });
|
|
return out;
|
|
}
|
|
|
|
function build() {
|
|
overlay = document.createElement("div");
|
|
overlay.className = "palette-overlay";
|
|
overlay.innerHTML =
|
|
'<div class="palette" role="dialog" aria-modal="true" aria-label="Search">' +
|
|
'<div class="palette-input-row"><span class="glyph" aria-hidden="true"></span>' +
|
|
'<input class="palette-input" type="text" placeholder="Search chats and actions\u2026" aria-label="Search" autocomplete="off" spellcheck="false" /></div>' +
|
|
'<div class="palette-list" role="listbox"></div>' +
|
|
'<div class="palette-foot"><span><kbd>\u2191</kbd> <kbd>\u2193</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Esc</kbd> close</span></div></div>';
|
|
document.body.appendChild(overlay);
|
|
field = overlay.querySelector(".palette-input");
|
|
list = overlay.querySelector(".palette-list");
|
|
field.addEventListener("input", function () { selected = 0; render(); });
|
|
field.addEventListener("keydown", function (e) {
|
|
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
|
|
else if (e.key === "Enter") { e.preventDefault(); choose(selected); }
|
|
});
|
|
overlay.addEventListener("mousedown", function (e) { if (e.target === overlay) close(); });
|
|
list.addEventListener("click", function (e) {
|
|
var row = e.target.closest(".palette-item");
|
|
if (row) choose(parseInt(row.getAttribute("data-i"), 10));
|
|
});
|
|
}
|
|
|
|
var shown = [];
|
|
function render() {
|
|
var q = field.value.trim().toLowerCase();
|
|
shown = collect().filter(function (it) { return !q || it.title.toLowerCase().indexOf(q) !== -1; });
|
|
if (!shown.length) { list.innerHTML = '<div class="palette-empty">No matches</div>'; return; }
|
|
var html = "", last = "";
|
|
shown.forEach(function (it, i) {
|
|
if (it.group !== last) { html += '<div class="palette-group"></div>'; last = it.group; }
|
|
html += '<div class="palette-item' + (i === selected ? " is-selected" : "") + '" role="option" data-i="' + i + '">' +
|
|
'<span class="glyph" style="--glyph: var(' + it.glyph + ')"></span>' +
|
|
'<span class="palette-item-title"></span>' + (it.hint ? '<span class="palette-item-hint"></span>' : "") + "</div>";
|
|
});
|
|
list.innerHTML = html;
|
|
// Fill text via textContent so conversation titles can never inject markup.
|
|
var groups = list.querySelectorAll(".palette-group"), g = 0; last = "";
|
|
var rows = list.querySelectorAll(".palette-item");
|
|
shown.forEach(function (it, i) {
|
|
if (it.group !== last) { groups[g++].textContent = it.group; last = it.group; }
|
|
rows[i].querySelector(".palette-item-title").textContent = it.title;
|
|
var h = rows[i].querySelector(".palette-item-hint"); if (h) h.textContent = it.hint;
|
|
});
|
|
}
|
|
function move(d) {
|
|
if (!shown.length) return;
|
|
selected = (selected + d + shown.length) % shown.length;
|
|
render();
|
|
var el = list.querySelector(".is-selected"); if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
|
|
}
|
|
function choose(i) {
|
|
var it = shown[i]; if (!it) return;
|
|
close();
|
|
if (it.run) it.run(); else if (it.href) window.location.href = it.href;
|
|
}
|
|
function open() {
|
|
if (!overlay) build();
|
|
overlay.classList.add("is-open");
|
|
field.value = ""; selected = 0; render(); field.focus();
|
|
}
|
|
function close() {
|
|
if (overlay) overlay.classList.remove("is-open");
|
|
if (input) input.focus();
|
|
}
|
|
if (trigger) trigger.addEventListener("click", open);
|
|
document.addEventListener("keydown", function (e) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
e.preventDefault();
|
|
overlay && overlay.classList.contains("is-open") ? close() : open();
|
|
} else if (e.key === "Escape" && overlay && overlay.classList.contains("is-open")) {
|
|
close();
|
|
}
|
|
});
|
|
})();
|
|
|
|
})();
|