feat: chat sidebar and inbox, feat: saved chats, fix: wake jobs, fix: sandbox sends, ux: negotiation replies and draft box

This commit is contained in:
2026-05-06 22:53:19 -04:00
parent 58a57ddc6a
commit 3b6e3c34d5
18 changed files with 1797 additions and 105 deletions
+329 -7
View File
@@ -29,9 +29,29 @@ const updateCheckButton = document.getElementById("update-check");
const updateInstallButton = document.getElementById("update-install");
const updateOpenReleasesButton = document.getElementById("update-open-releases");
const updateStatusEl = document.getElementById("update-status");
const shellEl = document.querySelector(".shell");
const chatRailEl = document.getElementById("chat-rail");
const chatSidebarToggle = document.getElementById("chat-sidebar-toggle");
const newChatButton = document.getElementById("new-chat");
const chatListEl = document.getElementById("chat-list");
const inboxListEl = document.getElementById("inbox-list");
const negotiationPanel = document.getElementById("negotiation-panel");
const negotiationTitle = document.getElementById("negotiation-title");
const negotiationMessagesEl = document.getElementById("negotiation-messages");
const negotiationForm = document.getElementById("negotiation-form");
const negotiationInput = document.getElementById("negotiation-input");
const negotiationStatusEl = document.getElementById("negotiation-status");
const negotiationCloseButton = document.getElementById("negotiation-close");
const updateModal = document.getElementById("update-modal");
const updateModalCopy = document.getElementById("update-modal-copy");
const updateModalClose = document.getElementById("update-modal-close");
const updateModalInstall = document.getElementById("update-modal-install");
const updateModalReleases = document.getElementById("update-modal-releases");
let ollamaOnline = true;
let latestUpdate = null;
let currentThreadId = "default";
let currentNegotiationId = null;
if (window.lucide) {
window.lucide.createIcons();
@@ -49,6 +69,7 @@ function addMessage(role, text) {
function setMessageMarkdown(node, text) {
const body = node.querySelector(".message-body") || node;
body.innerHTML = renderMarkdown(text);
enhanceNegotiationLinks(body);
}
function setMessageActivity(node, text, active = false) {
@@ -342,6 +363,43 @@ function inlineMarkdown(text) {
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
function enhanceNegotiationLinks(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
if (node.parentElement?.closest("a, button, code, pre")) return NodeFilter.FILTER_REJECT;
return /(?:negotiation|id_negotiation|marketplace\/(?:negotiations|negotiate\/hash))/i.test(node.textContent || "")
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
const pattern = /((?:negotiation|id_negotiation)\s*(?:#|id|:)?\s*)([A-Za-z0-9_-]{3,})|(\/marketplace\/(?:negotiations|negotiate\/hash)\/)([A-Za-z0-9_-]+)/gi;
for (const textNode of textNodes) {
const text = textNode.textContent || "";
let lastIndex = 0;
const fragment = document.createDocumentFragment();
for (const match of text.matchAll(pattern)) {
const matchIndex = match.index || 0;
const identifier = match[2] || match[4];
if (!identifier) continue;
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)));
if (match[1]) fragment.appendChild(document.createTextNode(match[1]));
if (match[3]) fragment.appendChild(document.createTextNode(match[3]));
const button = document.createElement("button");
button.type = "button";
button.className = "negotiation-link";
button.dataset.negotiationId = identifier;
button.textContent = identifier;
button.addEventListener("click", () => openNegotiationPanel(identifier));
fragment.appendChild(button);
lastIndex = matchIndex + match[0].length;
}
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
textNode.replaceWith(fragment);
}
}
function isTableRow(line) {
const trimmed = line.trim();
return trimmed.includes("|") && /^\|?.+\|.+\|?$/.test(trimmed);
@@ -576,7 +634,7 @@ function configuredOllamaModel() {
return document.getElementById("ollama-model")?.value || "";
}
async function checkForUpdate() {
async function checkForUpdate(promptUser = false) {
if (!updateStatusEl) return;
updateStatusEl.textContent = "Checking releases";
try {
@@ -584,6 +642,7 @@ async function checkForUpdate() {
const result = await response.json();
latestUpdate = result;
renderUpdateStatus(result);
if (promptUser) maybeShowUpdatePrompt(result);
} catch (error) {
updateStatusEl.textContent = `Update check failed: ${error.message}`;
if (updateInstallButton) updateInstallButton.disabled = true;
@@ -627,6 +686,22 @@ function openReleasesPage() {
window.open(url, "_blank", "noreferrer");
}
function maybeShowUpdatePrompt(update) {
if (!update?.available || !updateModal) return;
const dismissedVersion = localStorage.getItem("traderai.dismissedUpdateVersion");
if (dismissedVersion === update.latest_version) return;
updateModalCopy.textContent = update.message || `TraderAI ${update.latest_version} is available.`;
updateModalInstall.disabled = !update.asset_download_url || !update.packaged;
updateModal.hidden = false;
}
function closeUpdatePrompt() {
if (latestUpdate?.latest_version) {
localStorage.setItem("traderai.dismissedUpdateVersion", latestUpdate.latest_version);
}
updateModal.hidden = true;
}
function toggleSidebarPanel(panelName) {
const panels = {
settings: { panel: settingsPanel, button: settingsToggle },
@@ -658,6 +733,245 @@ function toggleSidebarPanel(panelName) {
}
}
function toggleChatRail() {
const isCollapsed = chatRailEl?.classList.toggle("collapsed");
shellEl?.classList.toggle("chat-open", !isCollapsed);
chatSidebarToggle?.setAttribute("aria-expanded", String(!isCollapsed));
}
async function refreshChats() {
if (!chatListEl) return;
try {
const response = await fetch("/api/chats");
const result = await response.json();
const chats = result.chats || [];
if (!chats.length) {
const created = await createChat(false);
currentThreadId = created?.id || "default";
return;
}
if (!chats.some((chat) => chat.id === currentThreadId)) {
currentThreadId = chats[0].id;
}
renderChats(chats);
} catch (error) {
chatListEl.textContent = `Chats failed: ${fetchErrorMessage(error)}`;
}
}
function renderChats(chats) {
chatListEl.innerHTML = "";
for (const chat of chats) {
const item = document.createElement("div");
item.className = `chat-item${chat.id === currentThreadId ? " active" : ""}`;
const title = document.createElement("button");
title.type = "button";
title.className = "chat-title";
title.textContent = chat.title || "New chat";
title.addEventListener("click", () => switchChat(chat.id));
const rename = document.createElement("button");
rename.type = "button";
rename.className = "icon-button";
rename.title = "Rename chat";
rename.innerHTML = '<i data-lucide="pen" aria-hidden="true"></i>';
rename.addEventListener("click", (event) => {
event.stopPropagation();
renameChat(chat.id, chat.title || "New chat");
});
const remove = document.createElement("button");
remove.type = "button";
remove.className = "icon-button";
remove.title = "Delete chat";
remove.innerHTML = '<i data-lucide="trash-2" aria-hidden="true"></i>';
remove.addEventListener("click", (event) => {
event.stopPropagation();
deleteChat(chat.id);
});
item.append(title, rename, remove);
chatListEl.appendChild(item);
}
if (window.lucide) window.lucide.createIcons();
}
async function createChat(shouldSwitch = true) {
const response = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "New chat" }),
});
const result = await response.json();
const chat = result.chat;
if (shouldSwitch && chat?.id) {
currentThreadId = chat.id;
await loadChatMessages(chat.id);
}
await refreshChats();
return chat;
}
async function switchChat(threadId) {
if (!threadId || threadId === currentThreadId) return;
currentThreadId = threadId;
await loadChatMessages(threadId);
await refreshChats();
}
async function deleteChat(threadId) {
await fetch(`/api/chats/${encodeURIComponent(threadId)}`, { method: "DELETE" });
if (threadId === currentThreadId) currentThreadId = "";
await refreshChats();
if (currentThreadId) await loadChatMessages(currentThreadId);
}
async function renameChat(threadId, currentTitle) {
const title = window.prompt("Rename chat", currentTitle || "New chat");
if (title === null) return;
const cleanTitle = title.trim();
if (!cleanTitle) return;
await fetch(`/api/chats/${encodeURIComponent(threadId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: cleanTitle }),
});
await refreshChats();
}
async function loadChatMessages(threadId) {
messages.innerHTML = "";
try {
const response = await fetch(`/api/chats/${encodeURIComponent(threadId)}/messages`);
const result = await response.json();
const rows = result.messages || [];
if (!rows.length) {
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
return;
}
for (const row of rows) {
if (row.role === "user" || row.role === "assistant") addMessage(row.role, row.content || "");
}
} catch (error) {
addMessage("assistant warning-message", `Could not load chat: ${fetchErrorMessage(error)}`);
}
}
async function refreshInbox() {
if (!inboxListEl) return;
try {
const response = await fetch("/api/inbox");
const result = await response.json();
renderInbox(result.inbox || []);
} catch (error) {
inboxListEl.textContent = `Inbox failed: ${fetchErrorMessage(error)}`;
}
}
function renderInbox(items) {
inboxListEl.innerHTML = "";
if (!items.length) {
inboxListEl.innerHTML = '<div class="pending-empty">No inbox messages</div>';
return;
}
for (const item of items) {
const row = document.createElement("div");
row.className = "inbox-item";
const title = document.createElement("div");
title.className = "inbox-title";
title.textContent = item.content || "";
enhanceNegotiationLinks(title);
const open = document.createElement("button");
open.type = "button";
open.className = "icon-button";
open.title = "Continue in new chat";
open.innerHTML = '<i data-lucide="message-square-plus" aria-hidden="true"></i>';
open.addEventListener("click", () => continueInbox(item.id));
const remove = document.createElement("button");
remove.type = "button";
remove.className = "icon-button";
remove.title = "Delete inbox alert";
remove.innerHTML = '<i data-lucide="trash-2" aria-hidden="true"></i>';
remove.addEventListener("click", () => deleteInboxItem(item.id));
row.append(title, open, remove);
inboxListEl.appendChild(row);
}
if (window.lucide) window.lucide.createIcons();
}
async function continueInbox(id) {
const response = await fetch(`/api/inbox/${id}/continue`, { method: "POST" });
const result = await response.json();
if (result.chat?.id) {
currentThreadId = result.chat.id;
await loadChatMessages(currentThreadId);
await refreshChats();
}
}
async function deleteInboxItem(id) {
await fetch(`/api/inbox/${id}`, { method: "DELETE" });
await refreshInbox();
}
async function openNegotiationPanel(identifier) {
currentNegotiationId = identifier;
negotiationPanel.hidden = false;
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationStatusEl.textContent = "";
negotiationMessagesEl.textContent = "Loading";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`);
const result = await response.json();
renderNegotiationMessages(result.data || result.messages || result.notifications || []);
} catch (error) {
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
}
}
function closeNegotiationPanel() {
negotiationPanel.hidden = true;
currentNegotiationId = null;
negotiationInput.value = "";
negotiationStatusEl.textContent = "";
}
function renderNegotiationMessages(data) {
negotiationMessagesEl.innerHTML = "";
const items = Array.isArray(data) ? data : [data].filter(Boolean);
if (!items.length) {
negotiationMessagesEl.textContent = "No messages returned for this negotiation.";
return;
}
for (const item of items) {
const card = document.createElement("div");
card.className = "negotiation-message";
const author = item.user_username || item.username || item.author || item.sender || "UEX";
const body = item.message || item.content || item.text || JSON.stringify(item, null, 2);
card.innerHTML = `<strong>${escapeHtml(String(author))}</strong><br>${inlineMarkdown(String(body))}`;
negotiationMessagesEl.appendChild(card);
}
negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight;
}
async function submitNegotiationMessage(event) {
event.preventDefault();
const text = negotiationInput.value.trim();
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Sending";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationInput.value = "";
negotiationStatusEl.textContent = result.message || "Sent";
await openNegotiationPanel(currentNegotiationId);
} catch (error) {
negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`;
}
}
async function checkHealth() {
try {
const response = await fetch("/api/health");
@@ -833,9 +1147,7 @@ async function pollNotifications() {
try {
const response = await fetch("/api/notifications");
const result = await response.json();
for (const notification of result.notifications || []) {
addMessage("assistant", notification.content);
}
if ((result.notifications || []).length) await refreshInbox();
} catch {
// Notification polling should never interrupt chat.
}
@@ -869,6 +1181,13 @@ ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/
updateCheckButton?.addEventListener("click", checkForUpdate);
updateInstallButton?.addEventListener("click", installUpdate);
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
chatSidebarToggle?.addEventListener("click", toggleChatRail);
newChatButton?.addEventListener("click", () => createChat(true));
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
negotiationForm?.addEventListener("submit", submitNegotiationMessage);
updateModalClose?.addEventListener("click", closeUpdatePrompt);
updateModalReleases?.addEventListener("click", openReleasesPage);
updateModalInstall?.addEventListener("click", installUpdate);
async function sendMessage() {
const message = input.value.trim();
@@ -892,7 +1211,7 @@ async function sendMessage() {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
body: JSON.stringify({ message, thread_id: currentThreadId }),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
@@ -926,6 +1245,7 @@ async function sendMessage() {
}
messages.scrollTop = messages.scrollHeight;
} else if (event.type === "done") {
if (event.thread_id) currentThreadId = event.thread_id;
const visibleContent = thinkParser.flush();
if (visibleContent) {
assistantText += visibleContent;
@@ -948,15 +1268,17 @@ async function sendMessage() {
statusEl.textContent = "Ready";
finishThinking(assistantNode);
setMessageActivity(assistantNode, "");
await refreshChats();
}
}
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
refreshPending();
refreshMemory();
refreshConfig();
refreshOllamaStatus();
checkForUpdate();
refreshChats().then(() => loadChatMessages(currentThreadId));
refreshInbox();
checkForUpdate(true);
pollNotifications();
checkHealth();
setInterval(checkHealth, 30000);