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:
+329
-7
@@ -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);
|
||||
|
||||
@@ -9,6 +9,28 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats and inbox">
|
||||
<div class="chat-rail-top">
|
||||
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
|
||||
<i data-lucide="panel-left" aria-hidden="true"></i>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
<button class="icon-button" id="new-chat" type="button" title="New chat">
|
||||
<i data-lucide="square-pen" aria-hidden="true"></i>
|
||||
<span>New chat</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-rail-content">
|
||||
<section class="chat-nav-section">
|
||||
<div class="rail-heading">Chats</div>
|
||||
<div class="chat-list" id="chat-list"></div>
|
||||
</section>
|
||||
<section class="chat-nav-section">
|
||||
<div class="rail-heading">Inbox</div>
|
||||
<div class="inbox-list" id="inbox-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="brand-block">
|
||||
@@ -120,6 +142,38 @@
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
<div class="floating-panel" id="negotiation-panel" hidden>
|
||||
<div class="floating-panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">UEX negotiation</p>
|
||||
<h2 id="negotiation-title">Negotiation</h2>
|
||||
</div>
|
||||
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="negotiation-messages" id="negotiation-messages"></div>
|
||||
<form class="negotiation-composer" id="negotiation-form">
|
||||
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<div class="config-status" id="negotiation-status"></div>
|
||||
</div>
|
||||
<div class="modal-backdrop" id="update-modal" hidden>
|
||||
<section class="update-modal-card">
|
||||
<div class="section-title-row">
|
||||
<h2>Update Available</h2>
|
||||
<button class="icon-button light" id="update-modal-close" type="button" title="Close">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p id="update-modal-copy"></p>
|
||||
<div class="update-actions">
|
||||
<button class="secondary small-button" id="update-modal-releases" type="button">Releases</button>
|
||||
<button class="small-button" id="update-modal-install" type="button">Update</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
+311
-8
@@ -22,6 +22,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
@@ -50,7 +54,7 @@ body::before {
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
grid-template-columns: 64px minmax(0, 1fr) 380px;
|
||||
gap: 24px;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
@@ -58,8 +62,13 @@ body::before {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.shell.chat-open {
|
||||
grid-template-columns: 280px minmax(0, 1fr) 380px;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.actions {
|
||||
.actions,
|
||||
.chat-rail {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(240, 214, 129, 0.34);
|
||||
@@ -69,12 +78,126 @@ body::before {
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-rail {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(180deg, #fffaf0 0%, #f7f1dc 100%);
|
||||
}
|
||||
|
||||
.chat-rail-top {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shell.chat-open .chat-rail-top {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.chat-rail-content {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(140px, 34%);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding-top: 16px;
|
||||
opacity: 1;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.chat-rail.collapsed .chat-rail-content {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-nav-section {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-heading {
|
||||
margin-bottom: 8px;
|
||||
color: var(--forest);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chat-list,
|
||||
.inbox-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: calc(100% - 26px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-item,
|
||||
.inbox-item {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 250, 240, 0.78);
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.inbox-item {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.chat-item.active {
|
||||
border-color: rgba(52, 83, 38, 0.42);
|
||||
background: #edf3df;
|
||||
}
|
||||
|
||||
.chat-title,
|
||||
.inbox-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--brown);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-title:hover {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.inbox-title {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
@@ -203,6 +326,7 @@ h2 {
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 28px 28px 18px;
|
||||
@@ -212,13 +336,15 @@ h2 {
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 920px;
|
||||
width: fit-content;
|
||||
max-width: min(920px, 88%);
|
||||
margin-bottom: 16px;
|
||||
padding: 17px 18px;
|
||||
border: 1px solid rgba(221, 206, 176, 0.9);
|
||||
border-radius: 18px;
|
||||
color: var(--brown);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
box-shadow: 0 16px 38px rgba(38, 58, 27, 0.11);
|
||||
}
|
||||
|
||||
@@ -346,6 +472,7 @@ h2 {
|
||||
|
||||
.message.user {
|
||||
margin-left: auto;
|
||||
max-width: min(720px, 76%);
|
||||
border-color: rgba(52, 83, 38, 0.28);
|
||||
background: linear-gradient(180deg, #edf3df, #e5efd4);
|
||||
}
|
||||
@@ -360,7 +487,9 @@ h2 {
|
||||
}
|
||||
|
||||
.composer-wrap {
|
||||
position: sticky;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
margin-top: auto;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -582,6 +711,162 @@ button {
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 12px;
|
||||
background: #fff9e9;
|
||||
color: var(--forest);
|
||||
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
|
||||
}
|
||||
|
||||
.shell.chat-open .chat-rail-top .icon-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.icon-button span {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shell.chat-open .chat-rail-top .icon-button span {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
}
|
||||
|
||||
.icon-button.light {
|
||||
background: rgba(255, 250, 240, 0.86);
|
||||
}
|
||||
|
||||
.chat-item .icon-button,
|
||||
.inbox-item .icon-button {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.negotiation-link {
|
||||
display: inline;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--forest-2);
|
||||
font-family: inherit;
|
||||
font-weight: 800;
|
||||
text-decoration: underline;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.negotiation-link:hover {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.floating-panel {
|
||||
position: fixed;
|
||||
right: 28px;
|
||||
bottom: 28px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(180px, 360px) auto auto;
|
||||
width: min(520px, calc(100vw - 28px));
|
||||
max-height: min(760px, calc(100vh - 56px));
|
||||
border: 1px solid rgba(240, 214, 129, 0.42);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.floating-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(90deg, rgba(20, 33, 15, 0.96), rgba(38, 58, 27, 0.94));
|
||||
}
|
||||
|
||||
.floating-panel-header h2 {
|
||||
margin: 3px 0 0;
|
||||
color: var(--ivory);
|
||||
}
|
||||
|
||||
.negotiation-messages {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.negotiation-message {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 250, 240, 0.86);
|
||||
font-size: 13px;
|
||||
line-height: 1.42;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.negotiation-composer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.negotiation-composer textarea {
|
||||
min-height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(20, 33, 15, 0.42);
|
||||
}
|
||||
|
||||
.update-modal-card {
|
||||
width: min(440px, 100%);
|
||||
padding: 22px;
|
||||
border: 1px solid rgba(240, 214, 129, 0.42);
|
||||
border-radius: 18px;
|
||||
background: var(--ivory);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.update-modal-card p {
|
||||
margin: 6px 0 18px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: linear-gradient(180deg, #3d612c, #263e1b);
|
||||
box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
@@ -950,7 +1235,19 @@ pre {
|
||||
@media (max-width: 960px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(220px, 34vh);
|
||||
grid-template-rows: auto minmax(0, 1fr) minmax(220px, 34vh);
|
||||
}
|
||||
|
||||
.shell.chat-open {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chat-rail {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.chat-rail.collapsed {
|
||||
max-height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,7 +1294,8 @@ pre {
|
||||
}
|
||||
|
||||
.messages,
|
||||
.actions {
|
||||
.actions,
|
||||
.chat-rail {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
@@ -1015,6 +1313,11 @@ pre {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message,
|
||||
.message.user {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-activity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user