feat: chat

This commit is contained in:
2026-06-09 11:24:15 -04:00
parent 454bb57484
commit 8fac3d2bae
15 changed files with 2015 additions and 38 deletions
+305 -10
View File
@@ -15,6 +15,7 @@ const configPathsEl = document.getElementById("config-paths");
const settingsToggle = document.getElementById("settings-toggle");
const memoryToggle = document.getElementById("memory-toggle");
const plansToggle = document.getElementById("plans-toggle");
const negotiationsToggle = document.getElementById("negotiations-toggle");
const ollamaToggle = document.getElementById("ollama-toggle");
const settingsPanel = document.getElementById("settings-panel");
const memoryPanel = document.getElementById("memory-panel");
@@ -48,6 +49,32 @@ 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 negotiationListEl = document.getElementById("negotiation-list");
const negotiationsRefreshAllButton = document.getElementById("negotiations-refresh-all");
const negotiationPanelListEl = document.getElementById("negotiation-panel-list");
const negotiationSearchEl = document.getElementById("negotiation-search");
const negotiationFilterEl = document.getElementById("negotiation-filter");
const negotiationThreadHeaderEl = document.getElementById("negotiation-thread-header");
const negotiationMetaCardEl = document.getElementById("negotiation-meta-card");
const negotiationUserCardEl = document.getElementById("negotiation-user-card");
const negotiationRefreshButton = document.getElementById("negotiation-refresh-button");
const negotiationDraftButton = document.getElementById("negotiation-draft-button");
const negotiationOpenChatButton = document.getElementById("negotiation-open-chat");
const negotiationEndDealButton = document.getElementById("negotiation-end-deal");
const negotiationSyncPillEl = document.getElementById("negotiation-sync-pill");
const negotiationCloseModal = document.getElementById("negotiation-close-modal");
const negotiationCloseModalClose = document.getElementById("negotiation-close-modal-close");
const negotiationCloseForm = document.getElementById("negotiation-close-form");
const negotiationCloseStatusEl = document.getElementById("negotiation-close-status");
const closeDealClosedEl = document.getElementById("close-deal-closed");
const closeDealValueEl = document.getElementById("close-deal-value");
const closeCurrencyEl = document.getElementById("close-currency");
const closeClarityEl = document.getElementById("close-clarity");
const closeSpeedEl = document.getElementById("close-speed");
const closeRespectEl = document.getElementById("close-respect");
const closeFairnessEl = document.getElementById("close-fairness");
const closeCommentEl = document.getElementById("close-comment");
const closeDraftButton = document.getElementById("close-draft-button");
const updateModal = document.getElementById("update-modal");
const updateModalCopy = document.getElementById("update-modal-copy");
const updateModalClose = document.getElementById("update-modal-close");
@@ -66,6 +93,7 @@ let ollamaOnline = true;
let latestUpdate = null;
let currentThreadId = "default";
let currentNegotiationId = null;
let negotiationRows = [];
let latestOllamaStatus = null;
let composerImages = [];
const clickedOllamaActions = new Set();
@@ -1182,16 +1210,114 @@ async function deleteInboxItem(id) {
await refreshInbox();
}
async function refreshNegotiations(preserveCurrent = true) {
const status = negotiationFilterEl?.value || "open";
const search = negotiationSearchEl?.value?.trim() || "";
try {
const response = await fetch(`/api/negotiations?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}&limit=100`);
const result = await response.json();
negotiationRows = result.negotiations || [];
renderNegotiationLists(negotiationRows);
if (!preserveCurrent) return;
if (!currentNegotiationId && negotiationRows.length) currentNegotiationId = negotiationRows[0].hash;
if (currentNegotiationId && negotiationRows.some((item) => item.hash === currentNegotiationId) && !negotiationPanel.hidden) {
await loadNegotiationDetail(currentNegotiationId, false);
}
} catch (error) {
const message = `Negotiations failed: ${fetchErrorMessage(error)}`;
if (negotiationListEl) negotiationListEl.textContent = message;
if (negotiationPanelListEl) negotiationPanelListEl.textContent = message;
}
}
async function refreshAllNegotiations() {
const previous = negotiationsRefreshAllButton?.disabled;
if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = true;
if (negotiationStatusEl) negotiationStatusEl.textContent = "Refreshing all negotiations";
try {
const response = await fetch("/api/negotiations/refresh-all", { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
await refreshNegotiations(true);
if (negotiationStatusEl) {
negotiationStatusEl.textContent = `Refreshed ${result.count || 0} negotiations`;
}
} catch (error) {
if (negotiationStatusEl) negotiationStatusEl.textContent = `Refresh all failed: ${fetchErrorMessage(error)}`;
} finally {
if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = Boolean(previous);
}
}
function renderNegotiationLists(items) {
renderNegotiationListInto(negotiationListEl, items.slice(0, 8));
renderNegotiationListInto(negotiationPanelListEl, items);
}
function renderNegotiationListInto(container, items) {
if (!container) return;
container.innerHTML = "";
if (!items.length) {
container.innerHTML = '<div class="pending-empty">No negotiations</div>';
return;
}
for (const item of items) {
const row = document.createElement("button");
row.type = "button";
row.className = `negotiation-row${item.hash === currentNegotiationId ? " active" : ""}`;
row.addEventListener("click", () => openNegotiationPanel(item.hash));
const top = document.createElement("div");
top.className = "negotiation-row-top";
const title = document.createElement("div");
title.className = "negotiation-row-title";
title.textContent = item.title || item.counterparty_username || item.hash;
const badge = document.createElement("span");
badge.className = `negotiation-row-badge ${item.status === "closed" ? "closed" : ""}`;
badge.textContent = item.status || "open";
top.append(title, badge);
const meta = document.createElement("div");
meta.className = "negotiation-row-meta";
meta.textContent = [
item.counterparty_username || "Unknown user",
item.last_message_at ? formatShortDate(item.last_message_at) : "No messages",
].join(" • ");
row.append(top, meta);
if (Number(item.unread_count || 0) > 0) {
const unread = document.createElement("span");
unread.className = "negotiation-row-unread";
unread.textContent = String(item.unread_count);
row.appendChild(unread);
}
container.appendChild(row);
}
}
async function openNegotiationPanel(identifier) {
if (!identifier) {
negotiationPanel.hidden = false;
negotiationsToggle?.setAttribute("aria-expanded", "true");
return;
}
currentNegotiationId = identifier;
negotiationPanel.hidden = false;
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationsToggle?.setAttribute("aria-expanded", "true");
negotiationStatusEl.textContent = "";
negotiationSyncPillEl.textContent = "Local sync";
await loadNegotiationDetail(identifier, true);
}
async function loadNegotiationDetail(identifier, refreshList = true) {
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationMessagesEl.textContent = "Loading";
negotiationThreadHeaderEl.innerHTML = '<div class="muted">Loading local thread...</div>';
negotiationMetaCardEl.innerHTML = "<h3>Deal</h3><div class='muted'>Loading</div>";
negotiationUserCardEl.innerHTML = "<h3>User</h3><div class='muted'>Loading</div>";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`);
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}`);
const result = await response.json();
renderNegotiationMessages(result.data || result.messages || result.notifications || []);
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderNegotiationDetail(result.negotiation);
if (refreshList) await refreshNegotiations(false);
} catch (error) {
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
}
@@ -1202,6 +1328,7 @@ function closeNegotiationPanel() {
currentNegotiationId = null;
negotiationInput.value = "";
negotiationStatusEl.textContent = "";
negotiationsToggle?.setAttribute("aria-expanded", "false");
}
function openPlansPanel(openPlanId = null) {
@@ -1217,6 +1344,37 @@ function closePlansPanel() {
plansToggle?.setAttribute("aria-expanded", "false");
}
function renderNegotiationDetail(negotiation) {
if (!negotiation) return;
negotiationTitle.textContent = negotiation.title || negotiation.counterparty_username || negotiation.hash;
negotiationSyncPillEl.textContent = negotiation.last_synced_at ? `Synced ${formatShortDate(negotiation.last_synced_at)}` : "Local sync";
negotiationThreadHeaderEl.innerHTML = `
<div><strong>${escapeHtml(negotiation.title || "Negotiation")}</strong></div>
<div class="muted">${escapeHtml(negotiation.counterparty_username || "Unknown user")}${escapeHtml(negotiation.status || "open")}${escapeHtml(negotiation.hash || "")}</div>
`;
renderNegotiationMessages(negotiation.messages || []);
const raw = negotiation.metadata?.raw || {};
negotiationMetaCardEl.innerHTML = `
<h3>Deal</h3>
<div class="negotiation-detail-kv">
<div><strong>Listing</strong> ${escapeHtml(negotiation.title || raw.listing_title || negotiation.hash)}</div>
<div><strong>Status</strong> ${escapeHtml(negotiation.status || "open")}</div>
<div><strong>Slug</strong> ${escapeHtml(negotiation.listing_slug || raw.listing_slug || "Unknown")}</div>
<div><strong>Price</strong> ${escapeHtml(String(raw.price || raw.deal_value || "Unknown"))} ${escapeHtml(String(raw.currency || raw.deal_value_currency || ""))}</div>
<div><strong>Last message</strong> ${escapeHtml(negotiation.last_message_at ? formatShortDate(negotiation.last_message_at) : "Unknown")}</div>
</div>
`;
negotiationUserCardEl.innerHTML = `
<h3>User</h3>
<div class="negotiation-detail-kv">
<div><strong>Counterparty</strong> ${escapeHtml(negotiation.counterparty_username || raw.client_username || raw.advertiser_username || "Unknown")}</div>
<div><strong>Advertiser</strong> ${escapeHtml(String(raw.advertiser_username || raw.advertiser_name || "Unknown"))}</div>
<div><strong>Client</strong> ${escapeHtml(String(raw.client_username || raw.client_name || "Unknown"))}</div>
<div><strong>Unread</strong> ${escapeHtml(String(negotiation.unread_count || 0))}</div>
</div>
`;
}
function renderNegotiationMessages(data) {
negotiationMessagesEl.innerHTML = "";
const items = Array.isArray(data) ? data : [data].filter(Boolean);
@@ -1226,10 +1384,15 @@ function renderNegotiationMessages(data) {
}
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))}`;
card.className = `negotiation-message${item.is_me ? " self" : ""}`;
const author = item.author_username || item.user_username || item.username || item.author || item.sender || "UEX";
const body = item.body || item.message || item.content || item.text || JSON.stringify(item, null, 2);
const meta = document.createElement("div");
meta.className = "negotiation-message-meta";
meta.innerHTML = `<strong>${escapeHtml(String(author))}</strong><span>${escapeHtml(item.sent_at ? formatShortDate(item.sent_at) : "")}</span>`;
const text = document.createElement("div");
text.innerHTML = inlineMarkdown(String(body));
card.append(meta, text);
negotiationMessagesEl.appendChild(card);
}
negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight;
@@ -1241,7 +1404,7 @@ async function submitNegotiationMessage(event) {
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Sending";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
@@ -1250,12 +1413,125 @@ async function submitNegotiationMessage(event) {
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationInput.value = "";
negotiationStatusEl.textContent = result.message || "Sent";
await openNegotiationPanel(currentNegotiationId);
if (result.negotiation) renderNegotiationDetail(result.negotiation);
await refreshNegotiations(false);
} catch (error) {
negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`;
}
}
async function draftNegotiationMessage() {
const text = negotiationInput.value.trim();
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Drafting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/draft`, {
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}`);
negotiationStatusEl.textContent = "Draft ready for approval";
await refreshPending();
} catch (error) {
negotiationStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`;
}
}
async function refreshActiveNegotiation() {
if (!currentNegotiationId) return;
negotiationStatusEl.textContent = "Refreshing";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/refresh`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.negotiation) renderNegotiationDetail(result.negotiation);
negotiationStatusEl.textContent = "Refreshed";
await refreshNegotiations(false);
} catch (error) {
negotiationStatusEl.textContent = `Refresh failed: ${fetchErrorMessage(error)}`;
}
}
async function openNegotiationInChat() {
if (!currentNegotiationId) return;
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/open-chat`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.chat?.id) {
currentThreadId = result.chat.id;
await loadChatMessages(currentThreadId);
await refreshChats();
}
} catch (error) {
negotiationStatusEl.textContent = `Open chat failed: ${fetchErrorMessage(error)}`;
}
}
function openNegotiationCloseModal() {
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "";
negotiationCloseModal.hidden = false;
}
function closeNegotiationCloseModal() {
negotiationCloseModal.hidden = true;
}
function negotiationClosePayload() {
return {
deal_closed: closeDealClosedEl.value !== "false",
deal_value: closeDealValueEl.value ? Number(closeDealValueEl.value) : null,
currency: closeCurrencyEl.value.trim() || null,
clarity_rating: closeClarityEl.value ? Number(closeClarityEl.value) : null,
speed_rating: closeSpeedEl.value ? Number(closeSpeedEl.value) : null,
respect_rating: closeRespectEl.value ? Number(closeRespectEl.value) : null,
fairness_rating: closeFairnessEl.value ? Number(closeFairnessEl.value) : null,
comment: closeCommentEl.value.trim() || null,
};
}
async function submitNegotiationClose(event) {
event.preventDefault();
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "Submitting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(negotiationClosePayload()),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationCloseStatusEl.textContent = result.message || "Submitted";
if (result.negotiation) renderNegotiationDetail(result.negotiation);
await refreshNegotiations(false);
closeNegotiationCloseModal();
} catch (error) {
negotiationCloseStatusEl.textContent = `Close failed: ${fetchErrorMessage(error)}`;
}
}
async function draftNegotiationClose() {
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "Drafting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(negotiationClosePayload()),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationCloseStatusEl.textContent = "Draft ready for approval";
await refreshPending();
} catch (error) {
negotiationCloseStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`;
}
}
function parsePlanItems(text) {
return text
.split(/\r?\n/)
@@ -1854,7 +2130,10 @@ async function pollNotifications() {
try {
const response = await fetch("/api/notifications");
const result = await response.json();
if ((result.notifications || []).length) await refreshInbox();
if ((result.notifications || []).length) {
await refreshInbox();
await refreshNegotiations(true);
}
} catch {
// Notification polling should never interrupt chat.
}
@@ -1899,6 +2178,11 @@ plansToggle?.addEventListener("click", () => {
if (plansPanel?.hidden) openPlansPanel();
else closePlansPanel();
});
negotiationsToggle?.addEventListener("click", () => {
if (negotiationPanel?.hidden) openNegotiationPanel(currentNegotiationId || negotiationRows[0]?.hash || "");
else closeNegotiationPanel();
});
negotiationsRefreshAllButton?.addEventListener("click", refreshAllNegotiations);
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
plansRefreshButton?.addEventListener("click", () => refreshPlans());
plansCloseButton?.addEventListener("click", closePlansPanel);
@@ -1935,6 +2219,15 @@ chatSidebarToggle?.addEventListener("click", toggleChatRail);
newChatButton?.addEventListener("click", () => createChat(true));
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
negotiationForm?.addEventListener("submit", submitNegotiationMessage);
negotiationDraftButton?.addEventListener("click", draftNegotiationMessage);
negotiationRefreshButton?.addEventListener("click", refreshActiveNegotiation);
negotiationOpenChatButton?.addEventListener("click", openNegotiationInChat);
negotiationEndDealButton?.addEventListener("click", openNegotiationCloseModal);
negotiationSearchEl?.addEventListener("input", () => refreshNegotiations(false));
negotiationFilterEl?.addEventListener("change", () => refreshNegotiations(false));
negotiationCloseModalClose?.addEventListener("click", closeNegotiationCloseModal);
negotiationCloseForm?.addEventListener("submit", submitNegotiationClose);
closeDraftButton?.addEventListener("click", draftNegotiationClose);
updateModalClose?.addEventListener("click", closeUpdatePrompt);
updateModalReleases?.addEventListener("click", openReleasesPage);
updateModalInstall?.addEventListener("click", installUpdate);
@@ -2038,8 +2331,10 @@ refreshConfig();
refreshOllamaStatus();
refreshChats().then(() => loadChatMessages(currentThreadId));
refreshInbox();
refreshNegotiations(false);
checkForUpdate(true);
pollNotifications();
checkHealth();
setInterval(checkHealth, 30000);
setInterval(pollNotifications, 15000);
setInterval(() => refreshNegotiations(true), 15000);
+114 -11
View File
@@ -25,6 +25,20 @@
<div class="rail-heading">Chats</div>
<div class="chat-list" id="chat-list"></div>
</section>
<section class="chat-nav-section">
<div class="rail-heading-row">
<div class="rail-heading">Negotiations</div>
<div class="rail-heading-actions">
<button class="rail-icon-button" id="negotiations-refresh-all" type="button" title="Refresh all negotiations">
<i data-lucide="refresh-cw" aria-hidden="true"></i>
</button>
<button class="rail-icon-button" id="negotiations-toggle" type="button" title="Negotiations" aria-expanded="false" aria-controls="negotiation-panel">
<i data-lucide="messages-square" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="plans-rail-list" id="negotiation-list"></div>
</section>
<section class="chat-nav-section">
<div class="rail-heading-row">
<div class="rail-heading">Plans</div>
@@ -82,6 +96,7 @@
<label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label>
<label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label>
<label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label>
<label>UEX Close Endpoint<input id="config-uex-negotiation-close-endpoint" name="uex_negotiation_close_endpoint" type="text"></label>
<label>UEX Username<input id="config-traderai-user-name" name="traderai_user_name" type="text"></label>
<label>Memory DB Path<input id="config-traderai-memory-path" name="traderai_memory_path" type="text"></label>
<label>Notification Poll Seconds<input id="config-uex-notification-poll-seconds" name="uex_notification_poll_seconds" type="number" min="15" step="15"></label>
@@ -170,19 +185,107 @@
<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>
<p class="eyebrow">UEX negotiations</p>
<h2 id="negotiation-title">Negotiation workspace</h2>
</div>
<div class="floating-panel-actions">
<div class="negotiation-sync-pill" id="negotiation-sync-pill">Local sync</div>
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</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 class="negotiation-workspace">
<aside class="negotiation-sidebar">
<div class="negotiation-sidebar-controls">
<input id="negotiation-search" type="text" placeholder="Search negotiations">
<select id="negotiation-filter">
<option value="open">Open</option>
<option value="all">All</option>
<option value="closed">Closed</option>
</select>
</div>
<div class="negotiation-list-panel" id="negotiation-panel-list"></div>
</aside>
<section class="negotiation-thread-shell">
<div class="negotiation-thread-header" id="negotiation-thread-header">
<div class="muted">Select a negotiation to load the local thread.</div>
</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>
<div class="negotiation-composer-actions">
<button class="secondary small-button" id="negotiation-draft-button" type="button">Ask AI to Draft</button>
<button type="submit">Send</button>
</div>
</form>
<div class="config-status" id="negotiation-status"></div>
</section>
<aside class="negotiation-detail-rail">
<div class="negotiation-detail-card" id="negotiation-meta-card">
<h3>Deal</h3>
<div class="muted">No negotiation selected.</div>
</div>
<div class="negotiation-detail-card" id="negotiation-user-card">
<h3>User</h3>
<div class="muted">No negotiation selected.</div>
</div>
<div class="negotiation-detail-card">
<h3>Actions</h3>
<div class="negotiation-action-stack">
<button class="secondary small-button" id="negotiation-open-chat" type="button">Open in AI Chat</button>
<button class="small-button" id="negotiation-refresh-button" type="button">Refresh Thread</button>
<button class="danger-button small-button" id="negotiation-end-deal" type="button">End Deal</button>
</div>
</div>
</aside>
</div>
</div>
<div class="modal-backdrop" id="negotiation-close-modal" hidden>
<section class="update-modal-card negotiation-close-card">
<div class="section-title-row">
<h2>End Deal</h2>
<button class="icon-button light" id="negotiation-close-modal-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<form class="config-form" id="negotiation-close-form">
<label>Did you close a deal?
<select id="close-deal-closed">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</label>
<div class="plan-form-split">
<label>Deal value
<input id="close-deal-value" type="number" min="0" step="1" placeholder="1000000">
</label>
<label>Currency
<input id="close-currency" type="text" value="UEC">
</label>
</div>
<label>Clear, timely, and honest?
<input id="close-clarity" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Delivery or response time?
<input id="close-speed" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Respectful and easy to deal with?
<input id="close-respect" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Price or offer fairness?
<input id="close-fairness" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Comments
<textarea id="close-comment" rows="3" placeholder="Optional note"></textarea>
</label>
<div class="plan-form-actions">
<button class="secondary" id="close-draft-button" type="button">Draft for Approval</button>
<button type="submit">Rate Deal</button>
</div>
<div class="config-status" id="negotiation-close-status"></div>
</form>
</section>
</div>
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
<div class="floating-panel-header">
+219 -2
View File
@@ -105,7 +105,7 @@ body::before {
.chat-rail-content {
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(92px, 20%) minmax(130px, 30%);
grid-template-rows: minmax(0, 1.7fr) minmax(72px, 0.45fr) minmax(92px, 0.65fr) minmax(120px, 0.95fr);
gap: 16px;
min-height: 0;
padding-top: 16px;
@@ -139,6 +139,12 @@ body::before {
margin-bottom: 8px;
}
.rail-heading-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.rail-heading-row .rail-heading {
margin-bottom: 0;
}
@@ -962,6 +968,26 @@ button {
box-shadow: var(--shadow);
}
#negotiation-panel {
grid-template-rows: auto minmax(0, 1fr);
width: min(1280px, calc(100vw - 28px));
max-height: min(860px, calc(100vh - 56px));
}
.negotiation-sync-pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 6px 10px;
border: 1px solid rgba(240, 214, 129, 0.32);
border-radius: 999px;
background: rgba(255, 250, 240, 0.12);
color: var(--gold-2);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.floating-panel-header {
display: flex;
align-items: flex-start;
@@ -977,6 +1003,114 @@ button {
color: var(--ivory);
}
.negotiation-workspace {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 260px;
min-height: 0;
height: 100%;
}
.negotiation-sidebar,
.negotiation-thread-shell,
.negotiation-detail-rail {
min-height: 0;
}
.negotiation-sidebar {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-right: 1px solid var(--line);
background: rgba(255, 250, 240, 0.58);
}
.negotiation-sidebar-controls {
display: grid;
gap: 10px;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.negotiation-list-panel {
display: grid;
gap: 8px;
padding: 14px;
overflow: auto;
}
.negotiation-row {
display: grid;
gap: 6px;
padding: 11px 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 253, 247, 0.86);
cursor: pointer;
}
.negotiation-row.active {
border-color: rgba(52, 83, 38, 0.42);
background: #edf3df;
}
.negotiation-row-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.negotiation-row-title {
color: var(--forest);
font-size: 13px;
font-weight: 800;
}
.negotiation-row-meta {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.negotiation-row-badge {
padding: 3px 7px;
border-radius: 999px;
background: #edf3df;
color: var(--forest);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.negotiation-row-badge.closed {
background: #efe6ce;
color: var(--muted);
}
.negotiation-row-unread {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--forest);
color: var(--ivory);
font-size: 11px;
font-weight: 800;
}
.negotiation-thread-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
}
.negotiation-thread-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: rgba(255, 253, 247, 0.82);
}
.negotiation-messages {
overflow: auto;
padding: 16px;
@@ -993,9 +1127,25 @@ button {
overflow-wrap: anywhere;
}
.negotiation-message.self {
border-color: rgba(52, 83, 38, 0.28);
background: #edf3df;
}
.negotiation-message-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.negotiation-composer {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr;
gap: 10px;
padding: 12px;
border-top: 1px solid var(--line);
@@ -1006,6 +1156,64 @@ button {
border-radius: 12px;
}
.negotiation-composer-actions {
display: flex;
gap: 10px;
}
.negotiation-composer-actions button {
flex: 1;
}
.negotiation-detail-rail {
display: grid;
align-content: start;
gap: 12px;
padding: 14px;
border-left: 1px solid var(--line);
background: rgba(255, 250, 240, 0.52);
overflow: auto;
}
.negotiation-detail-card {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 253, 247, 0.86);
}
.negotiation-detail-card h3 {
margin: 0;
color: var(--forest);
font-size: 14px;
}
.negotiation-detail-kv {
display: grid;
gap: 6px;
}
.negotiation-detail-kv div {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.negotiation-detail-kv strong {
color: var(--brown);
}
.negotiation-action-stack {
display: grid;
gap: 10px;
}
.negotiation-close-card {
width: min(520px, 100%);
}
.modal-backdrop {
position: fixed;
inset: 0;
@@ -2077,4 +2285,13 @@ pre {
.plans-panel-body {
grid-template-columns: 1fr;
}
.negotiation-workspace {
grid-template-columns: 1fr;
}
.negotiation-sidebar,
.negotiation-detail-rail {
border: 0;
}
}