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);
+54
View File
@@ -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
View File
@@ -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;
}