Redesign tracker chat UI with session sidebar and browser-local history.

This commit is contained in:
Dobromir Popov
2026-07-07 18:25:32 +02:00
parent 456c43ea1d
commit f1eea5b6d4

View File

@@ -59,21 +59,84 @@
.dashboard-tabs button.active { color:var(--accent); border-bottom-color:var(--accent); } .dashboard-tabs button.active { color:var(--accent); border-bottom-color:var(--accent); }
.wide { grid-column:1 / -1; } .wide { grid-column:1 / -1; }
section[hidden] { display:none !important; } section[hidden] { display:none !important; }
.chat-shell { display:grid; grid-template-columns:minmax(0, 1.35fr) minmax(320px, 0.65fr); gap:12px; } .chat-app {
.chat-pane { display:flex; flex-direction:column; gap:10px; min-width:0; } display:grid; grid-template-columns:260px minmax(0, 1fr); gap:0;
.chat-panel { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:10px; } min-height:560px; border:1px solid var(--border); border-radius:8px;
.chat-controls { display:flex; gap:10px; align-items:end; flex-wrap:wrap; } overflow:hidden; background:var(--bg);
.chat-controls label { display:flex; flex-direction:column; gap:4px; color:var(--dim); } }
.chat-controls select { min-width:220px; } .chat-sidebar {
.chat-history { display:flex; flex-direction:column; gap:8px; min-height:220px; max-height:420px; overflow:auto; } display:flex; flex-direction:column; min-height:0;
.chat-message { border:1px solid #21262d; border-radius:6px; padding:8px 10px; background:#10151d; } border-right:1px solid var(--border); background:var(--panel);
.chat-role { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.06em; margin-bottom:4px; } }
.chat-role-user { color:var(--accent); } .chat-sidebar-header {
.chat-role-assistant { color:var(--ok); } display:flex; flex-direction:column; gap:8px; padding:12px;
.chat-role-error { color:var(--bad); } border-bottom:1px solid var(--border);
.chat-compose { display:flex; flex-direction:column; gap:8px; } }
.chat-compose textarea { min-height:112px; resize:vertical; width:100%; } .chat-sidebar-header button { width:100%; text-align:left; }
.chat-status { color:var(--dim); font-size:12px; } .chat-session-list {
flex:1; overflow:auto; padding:8px; display:flex; flex-direction:column; gap:4px;
}
.chat-session-item {
position:relative; display:block; width:100%; text-align:left;
padding:8px 28px 8px 10px; border:1px solid transparent; border-radius:6px;
background:transparent; color:var(--fg); cursor:pointer;
}
.chat-session-item:hover { background:#10151d; border-color:#21262d; }
.chat-session-item.active {
background:#10151d; border-color:var(--accent);
}
.chat-session-title {
font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.chat-session-meta {
margin-top:2px; font-size:11px; color:var(--dim);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.chat-session-delete {
position:absolute; top:6px; right:4px; padding:0 5px; min-width:0;
border:0; background:transparent; color:var(--dim); line-height:1.2;
}
.chat-session-delete:hover { color:var(--bad); }
.chat-main { display:flex; flex-direction:column; min-height:560px; min-width:0; }
.chat-main-header {
display:flex; gap:12px; align-items:center; flex-wrap:wrap;
padding:10px 12px; border-bottom:1px solid var(--border); background:var(--panel);
}
.chat-main-header label {
display:flex; align-items:center; gap:8px; color:var(--dim); margin:0;
}
.chat-main-header select { min-width:220px; }
.chat-main-title { flex:1; min-width:120px; font-size:12px; color:var(--fg); }
.chat-status { color:var(--dim); font-size:12px; margin-left:auto; }
.chat-messages {
flex:1; overflow:auto; padding:16px; display:flex; flex-direction:column; gap:12px;
min-height:280px;
}
.chat-messages.empty {
justify-content:center; align-items:center; color:var(--dim); font-style:italic;
}
.chat-bubble-wrap { display:flex; flex-direction:column; gap:4px; max-width:88%; }
.chat-bubble-wrap.user { align-self:flex-end; align-items:flex-end; }
.chat-bubble-wrap.assistant, .chat-bubble-wrap.error { align-self:flex-start; align-items:flex-start; }
.chat-bubble-label {
font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--dim);
}
.chat-bubble {
padding:10px 12px; border-radius:12px; line-height:1.45;
white-space:pre-wrap; word-break:break-word;
}
.chat-bubble.user { background:#1f3a5f; border:1px solid #264a73; border-bottom-right-radius:4px; }
.chat-bubble.assistant { background:#10151d; border:1px solid #21262d; border-bottom-left-radius:4px; }
.chat-bubble.error { background:#1a1012; border:1px solid #5c2020; color:#ffb4b4; border-bottom-left-radius:4px; }
.chat-compose {
display:flex; gap:8px; align-items:flex-end; padding:12px;
border-top:1px solid var(--border); background:var(--panel);
}
.chat-compose textarea {
flex:1; min-height:44px; max-height:160px; resize:none; width:auto;
}
.chat-compose button { align-self:flex-end; min-width:72px; }
.chat-hint { padding:0 12px 10px; color:var(--dim); font-size:11px; }
.console { .console {
background:var(--bg); border:1px solid var(--border); border-radius:6px; background:var(--bg); border:1px solid var(--border); border-radius:6px;
min-height:160px; max-height:280px; overflow:auto; padding:7px 9px; min-height:160px; max-height:280px; overflow:auto; padding:7px 9px;
@@ -112,27 +175,28 @@
<section data-tab="overview"><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section> <section data-tab="overview"><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
<section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section> <section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section>
<section data-tab="chat" class="wide"> <section data-tab="chat" class="wide">
<h2>Chat / inference</h2> <h2>Chat</h2>
<div class="chat-shell"> <div class="chat-app">
<div class="chat-pane"> <aside class="chat-sidebar">
<div class="chat-panel chat-controls"> <div class="chat-sidebar-header">
<button type="button" onclick="createNewChatSession()">+ New chat</button>
</div>
<div id="chat-session-list" class="chat-session-list empty">no chats yet</div>
</aside>
<div class="chat-main">
<div class="chat-main-header">
<label>Model <label>Model
<select id="chat-model" onchange="selectChatModel(this.value)"></select> <select id="chat-model" onchange="selectChatModel(this.value)"></select>
</label> </label>
<button class="small" onclick="clearChatHistory()">clear history</button> <div id="chat-main-title" class="chat-main-title">New chat</div>
</div>
<div class="chat-panel chat-compose">
<textarea id="chat-prompt" placeholder="Ask a question or describe the task"></textarea>
<div class="form-row">
<button onclick="sendChat()" id="chat-send">Send</button>
</div>
</div>
</div>
<div class="chat-pane">
<div class="chat-panel">
<div id="chat-status" class="chat-status">select a model to start</div> <div id="chat-status" class="chat-status">select a model to start</div>
<div id="chat-history" class="chat-history empty">no messages yet</div>
</div> </div>
<div id="chat-history" class="chat-messages empty">Send a message to start this conversation.</div>
<div class="chat-compose">
<textarea id="chat-prompt" placeholder="Message…" rows="1"></textarea>
<button type="button" onclick="sendChat()" id="chat-send">Send</button>
</div>
<div class="chat-hint">Enter to send · Shift+Enter for a new line · Sessions are saved in this browser</div>
</div> </div>
</div> </div>
</section> </section>
@@ -604,7 +668,198 @@ let lastStats = null;
let availableModels = []; let availableModels = [];
let chatHistory = []; let chatHistory = [];
let chatBusy = false; let chatBusy = false;
let chatSessions = [];
let activeChatSessionId = "";
let selectedChatModel = localStorage.getItem("meshnet_chat_model") || ""; let selectedChatModel = localStorage.getItem("meshnet_chat_model") || "";
const CHAT_SESSIONS_KEY = "meshnet_chat_sessions_v1";
const CHAT_ACTIVE_SESSION_KEY = "meshnet_chat_active_session_v1";
const CHAT_SESSIONS_LIMIT = 50;
function newChatSessionId() {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
return "chat-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
}
function loadChatSessionsStore() {
try {
const raw = localStorage.getItem(CHAT_SESSIONS_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveChatSessionsStore() {
localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(chatSessions));
if (activeChatSessionId) {
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
}
}
function chatSessionTitle(session) {
const firstUser = (session.messages || []).find(msg => msg.role === "user");
if (!firstUser || !firstUser.content) return "New chat";
const text = String(firstUser.content).trim().replace(/\s+/g, " ");
return text.length > 42 ? text.slice(0, 42) + "…" : text;
}
function formatSessionTime(iso) {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
if (sameDay) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
function getActiveChatSession() {
return chatSessions.find(session => session.id === activeChatSessionId) || null;
}
function persistActiveChatSession() {
const session = getActiveChatSession();
if (!session) return;
session.messages = chatHistory.slice();
session.model = selectedChatModel || session.model || "";
session.title = chatSessionTitle(session);
session.updatedAt = new Date().toISOString();
chatSessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
if (chatSessions.length > CHAT_SESSIONS_LIMIT) {
chatSessions = chatSessions.slice(0, CHAT_SESSIONS_LIMIT);
if (!chatSessions.some(item => item.id === activeChatSessionId)) {
activeChatSessionId = chatSessions[0].id;
chatHistory = chatSessions[0].messages.slice();
}
}
saveChatSessionsStore();
renderChatSessionList();
renderChatMainTitle();
}
function createNewChatSession() {
if (chatBusy) return;
const session = {
id: newChatSessionId(),
title: "New chat",
model: selectedChatModel || "",
messages: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
chatSessions.unshift(session);
activeChatSessionId = session.id;
chatHistory = [];
saveChatSessionsStore();
renderChatSessionList();
renderChatHistory();
renderChatMainTitle();
renderChatAuthHint();
const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus();
}
function selectChatSession(sessionId) {
if (chatBusy || sessionId === activeChatSessionId) return;
const session = chatSessions.find(item => item.id === sessionId);
if (!session) return;
activeChatSessionId = session.id;
chatHistory = (session.messages || []).slice();
if (session.model) {
selectedChatModel = session.model;
localStorage.setItem("meshnet_chat_model", selectedChatModel);
const select = $("chat-model");
if (select) select.value = selectedChatModel;
}
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
renderChatSessionList();
renderChatHistory();
renderChatMainTitle();
renderChatAuthHint();
}
function deleteChatSession(sessionId, event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (chatBusy) return;
const index = chatSessions.findIndex(item => item.id === sessionId);
if (index < 0) return;
chatSessions.splice(index, 1);
if (activeChatSessionId === sessionId) {
if (chatSessions.length) {
activeChatSessionId = chatSessions[0].id;
chatHistory = (chatSessions[0].messages || []).slice();
if (chatSessions[0].model) {
selectedChatModel = chatSessions[0].model;
localStorage.setItem("meshnet_chat_model", selectedChatModel);
}
} else {
saveChatSessionsStore();
createNewChatSession();
return;
}
}
saveChatSessionsStore();
renderChatSessionList();
renderChatHistory();
renderChatMainTitle();
renderChatModels();
}
function initChatSessions() {
chatSessions = loadChatSessionsStore();
activeChatSessionId = localStorage.getItem(CHAT_ACTIVE_SESSION_KEY) || "";
const active = chatSessions.find(session => session.id === activeChatSessionId);
if (!active) {
if (chatSessions.length) {
activeChatSessionId = chatSessions[0].id;
chatHistory = (chatSessions[0].messages || []).slice();
if (chatSessions[0].model) selectedChatModel = chatSessions[0].model;
} else {
createNewChatSession();
return;
}
} else {
chatHistory = (active.messages || []).slice();
if (active.model) selectedChatModel = active.model;
}
renderChatSessionList();
renderChatMainTitle();
renderChatHistory();
}
function renderChatMainTitle() {
const el = $("chat-main-title");
if (!el) return;
const session = getActiveChatSession();
el.textContent = session ? chatSessionTitle(session) : "New chat";
}
function renderChatSessionList() {
const list = $("chat-session-list");
if (!list) return;
if (!chatSessions.length) {
list.classList.add("empty");
list.innerHTML = "no chats yet";
return;
}
list.classList.remove("empty");
list.innerHTML = chatSessions.map(session => {
const active = session.id === activeChatSessionId ? " active" : "";
const title = esc(chatSessionTitle(session));
const model = esc(short(session.model || "no model", 18));
const when = esc(formatSessionTime(session.updatedAt || session.createdAt));
const id = JSON.stringify(session.id);
return `<button type="button" class="chat-session-item${active}" onclick="selectChatSession(${id})">` +
`<div class="chat-session-title">${title}</div>` +
`<div class="chat-session-meta">${model}${when ? " · " + when : ""}</div>` +
`<span class="chat-session-delete" role="button" title="Delete chat" onclick="deleteChatSession(${id}, event)">×</span>` +
`</button>`;
}).join("");
}
function switchDashboardTab(name) { function switchDashboardTab(name) {
if (name === "admin" && !isAdmin) name = "overview"; if (name === "admin" && !isAdmin) name = "overview";
@@ -632,17 +887,21 @@ function renderChatStatus(text) {
function renderChatHistory() { function renderChatHistory() {
const history = $("chat-history"); const history = $("chat-history");
if (!history) return;
if (!chatHistory.length) { if (!chatHistory.length) {
history.classList.add("empty"); history.classList.add("empty");
history.innerHTML = "no messages yet"; history.innerHTML = "Send a message to start this conversation.";
return; return;
} }
history.classList.remove("empty"); history.classList.remove("empty");
history.innerHTML = chatHistory.map(msg => { history.innerHTML = chatHistory.map(msg => {
const roleClass = msg.role === "user" ? "chat-role-user" : msg.role === "assistant" ? "chat-role-assistant" : "chat-role-error"; const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
const label = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error"; const label = role === "user" ? "You" : role === "assistant" ? "Assistant" : "Error";
const meta = msg.model ? ` <span class="dim">· ${esc(short(msg.model, 24))}</span>` : ""; const meta = msg.model ? ` <span class="dim">· ${esc(short(msg.model, 24))}</span>` : "";
return `<div class="chat-message"><div class="chat-role ${roleClass}">${label}${meta}</div><div>${esc(msg.content)}</div></div>`; return `<div class="chat-bubble-wrap ${role}">` +
`<div class="chat-bubble-label">${label}${meta}</div>` +
`<div class="chat-bubble ${role}">${esc(msg.content)}</div>` +
`</div>`;
}).join(""); }).join("");
history.scrollTop = history.scrollHeight; history.scrollTop = history.scrollHeight;
} }
@@ -674,12 +933,13 @@ function renderChatModels() {
function selectChatModel(value) { function selectChatModel(value) {
selectedChatModel = value || ""; selectedChatModel = value || "";
localStorage.setItem("meshnet_chat_model", selectedChatModel); localStorage.setItem("meshnet_chat_model", selectedChatModel);
} const session = getActiveChatSession();
if (session) {
function clearChatHistory() { session.model = selectedChatModel;
chatHistory = []; session.updatedAt = new Date().toISOString();
renderChatHistory(); saveChatSessionsStore();
renderChatStatus("history cleared"); renderChatSessionList();
}
} }
function chatAuthToken() { function chatAuthToken() {
@@ -937,6 +1197,7 @@ async function sendChat() {
promptEl.value = ""; promptEl.value = "";
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel }); chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
renderChatHistory(); renderChatHistory();
persistActiveChatSession();
renderChatStatus("sending request…"); renderChatStatus("sending request…");
const r = await apiCall("/v1/chat/completions", "POST", body, bearerToken); const r = await apiCall("/v1/chat/completions", "POST", body, bearerToken);
chatBusy = false; chatBusy = false;
@@ -947,6 +1208,7 @@ async function sendChat() {
: "request failed"; : "request failed";
chatHistory.push({ role: "error", content: error, model: selectedChatModel }); chatHistory.push({ role: "error", content: error, model: selectedChatModel });
renderChatHistory(); renderChatHistory();
persistActiveChatSession();
renderChatStatus(error); renderChatStatus(error);
promptEl.focus(); promptEl.focus();
return; return;
@@ -959,12 +1221,29 @@ async function sendChat() {
model: selectedChatModel, model: selectedChatModel,
}); });
renderChatHistory(); renderChatHistory();
persistActiveChatSession();
renderChatStatus(usage renderChatStatus(usage
? `done: ${usage.total_tokens ?? "?"} tokens` ? `done: ${usage.total_tokens ?? "?"} tokens`
: "done"); : "done");
promptEl.focus(); promptEl.focus();
} }
function bindChatPromptShortcuts() {
const promptEl = $("chat-prompt");
if (!promptEl || promptEl.dataset.bound === "1") return;
promptEl.dataset.bound = "1";
promptEl.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendChat();
}
});
promptEl.addEventListener("input", () => {
promptEl.style.height = "auto";
promptEl.style.height = Math.min(promptEl.scrollHeight, 160) + "px";
});
}
async function renderAdminPanel() { async function renderAdminPanel() {
const r = await apiCall("/v1/admin/accounts"); const r = await apiCall("/v1/admin/accounts");
if (!r.ok) { setAdminMode(false); return; } if (!r.ok) { setAdminMode(false); return; }
@@ -1034,6 +1313,8 @@ async function refresh() {
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString(); $("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
} }
refresh(); refresh();
initChatSessions();
bindChatPromptShortcuts();
renderAccountPanel(); renderAccountPanel();
renderChatModels(); renderChatModels();
renderChatHistory(); renderChatHistory();