From 6376739f4813a4d0c4ee17ec4cfa8f2935bce384 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti Date: Sat, 2 May 2026 03:25:03 -0300 Subject: [PATCH] Frontend: scroll fix, SSE reconnect, toast global, stale states, theming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 bloques en una pasada: A. Scroll fixes (síntoma reportado por el usuario): - run-timeline: nuevo flag _userScrolledUp con detección por rAF en bindScroll. Auto-scroll al final SOLO si el usuario está abajo (umbral 150px) o si él mismo disparó un optimistic bubble. Cuando el usuario manda mensaje, se resetea el flag y se scrollea. - conversation-inspector: mismo patrón. ui:chatScroll respeta el flag userScrolledUp del emisor para no perseguir al usuario que lee arriba. - .bubble: + min-width:0, overflow-wrap:anywhere para URLs/JSON largos sin espacios. -
: + overflow-x:auto, max-width:100%.
   - chat-simulator: textarea con resize:vertical, min/max heights
     viewport-friendly. inputs-col con overflow-y:auto.

B. Stale state options:
   - conversation-list y conversations-crud: dropdowns ahora muestran
     IDLE / CART / SHIPPING / AWAITING_HUMAN. Quitados BROWSING,
     BUILDING_ORDER, WAITING_ADDRESS, WAITING_PAYMENT, COMPLETED.
   - main.js: simulated plan.next_state pasa a CART.

C. SSE resilience (lib/sse.js):
   - connect() con backoff exponencial (1s → 30s).
   - safeParse() helper: cada evento envuelve JSON.parse en try-catch
     para que un payload malformado no rompa otros listeners.
   - reset retryDelay al primer "hello" exitoso.
   - ops-shell: indicador con dot (verde "En vivo" / naranja pulsante
     "Reconectando…") en lugar de texto plano.

D. Toast service global (public/lib/toast.js):
   - API simple: toast({ kind, text, ms }). Apila, animación slide-in,
     auto-dismiss 4s. Click para cerrar.
   - safeFetch en api.js: wrapper que dispara toast en network error y
     non-OK. Migrados simEvolution + retryLast.
   - chat-simulator usa toast en lugar de status text efímero.

E. Theming con CSS vars:
   - public/styles/theme.css con paleta completa (panels, borders,
     text, accents, bubbles, charts, radii, shadows). Linkeado desde
     index.html.
   - Migrados a var(--*) los 5 componentes más visibles:
     run-timeline, chat-simulator, conversation-inspector,
     conversation-list, home-dashboard. Custom properties heredan a
     través del shadow DOM, así que los demás componentes pueden
     migrar gradualmente sin cambios estructurales.
   - home-dashboard ya tenía vars locales: ahora apuntan a las globales.

Backend: 192/192 tests pasando. Sin cambios de API.

Co-Authored-By: Claude Sonnet 4.6 
---
 public/components/chat-simulator.js         | 34 +++++----
 public/components/conversation-inspector.js | 55 ++++++++++----
 public/components/conversation-list.js      | 23 +++---
 public/components/conversations-crud.js     |  8 +-
 public/components/home-dashboard.js         | 20 +++--
 public/components/ops-shell.js              | 13 +++-
 public/components/run-timeline.js           | 68 +++++++++--------
 public/index.html                           |  1 +
 public/lib/api.js                           | 38 +++++++++-
 public/lib/sse.js                           | 74 ++++++++++++++++---
 public/lib/toast.js                         | 81 +++++++++++++++++++++
 public/main.js                              |  2 +-
 public/styles/theme.css                     | 74 +++++++++++++++++++
 13 files changed, 387 insertions(+), 104 deletions(-)
 create mode 100644 public/lib/toast.js
 create mode 100644 public/styles/theme.css

diff --git a/public/components/chat-simulator.js b/public/components/chat-simulator.js
index 73aec1f..c3e5944 100644
--- a/public/components/chat-simulator.js
+++ b/public/components/chat-simulator.js
@@ -1,6 +1,7 @@
 import { api } from "../lib/api.js";
 import { emit, on } from "../lib/bus.js";
 import { modal } from "../lib/modal.js";
+import { toast } from "../lib/toast.js";
 
 class ChatSimulator extends HTMLElement {
   constructor() {
@@ -12,23 +13,23 @@ class ChatSimulator extends HTMLElement {
       
 
       
@@ -167,14 +168,15 @@ class ChatSimulator extends HTMLElement { console.log("[evolution sim] webhook response:", data); if (!data.ok) { - statusEl.textContent = "Error enviando (ver consola)"; + toast({ kind: "error", text: `Sim Evolution: ${data.error || "respuesta no-ok"}` }); return; } evoTextEl.value = ""; evoTextEl.focus(); } catch (e) { - statusEl.textContent = `Error: ${String(e?.message || e)}`; + // safeFetch ya disparó toast; sólo logueamos. + console.error("[chat-simulator] send error:", e); } finally { setSending(false); } @@ -187,10 +189,10 @@ class ChatSimulator extends HTMLElement { api .retryLast(chat_id) .then((r) => { - if (!r?.ok) statusEl.textContent = `Retry error: ${r?.error || "unknown"}`; - else statusEl.textContent = "Retry enviado."; + if (!r?.ok) toast({ kind: "error", text: `Retry: ${r?.error || "fallo"}` }); + else toast({ kind: "ok", text: "Retry enviado." }); }) - .catch((e) => (statusEl.textContent = `Retry error: ${String(e?.message || e)}`)) + .catch((e) => console.error("[chat-simulator] retry error:", e)) .finally(() => setSending(false)); }; retryEl.disabled = false; diff --git a/public/components/conversation-inspector.js b/public/components/conversation-inspector.js index dd58e74..1f4ac72 100644 --- a/public/components/conversation-inspector.js +++ b/public/components/conversation-inspector.js @@ -14,34 +14,36 @@ class ConversationInspector extends HTMLElement { this._playing = false; this._playIdx = 0; this._timer = null; + this._userScrolledUp = false; + this._scrollRaf = null; this.shadowRoot.innerHTML = `
@@ -83,8 +85,11 @@ class ConversationInspector extends HTMLElement { this.applyHeights(); }); - this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop }) => { + this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop, userScrolledUp }) => { if (!this.chatId || chat_id !== this.chatId) return; + // Si el otro panel está scrolleado-up, sincronizamos. Si no, dejamos + // a este panel manejar su propio scroll para evitar saltos cruzados. + if (!userScrolledUp) return; const list = this.shadowRoot.getElementById("list"); list.scrollTop = scrollTop || 0; }); @@ -336,8 +341,24 @@ class ConversationInspector extends HTMLElement { this.rowOrder.push(msgId); } - // Auto-scroll al final - list.scrollTop = list.scrollHeight; + // Auto-scroll al final, salvo que el usuario esté leyendo arriba. + if (!this._userScrolledUp) { + list.scrollTop = list.scrollHeight; + } + this._bindScroll(list); + } + + _bindScroll(list) { + if (this._scrollBound) return; + this._scrollBound = true; + list.addEventListener("scroll", () => { + if (this._scrollRaf) return; + this._scrollRaf = requestAnimationFrame(() => { + this._scrollRaf = null; + const distFromBottom = list.scrollHeight - list.scrollTop - list.clientHeight; + this._userScrolledUp = distFromBottom > 150; + }); + }); } applyHeights() { @@ -438,7 +459,9 @@ class ConversationInspector extends HTMLElement { `; list.appendChild(el); + // Optimistic: el usuario acaba de mandar — forzamos al final. list.scrollTop = list.scrollHeight; + this._userScrolledUp = false; this.rowMap.set(msg.message_id, el); this.rowOrder.push(msg.message_id); diff --git a/public/components/conversation-list.js b/public/components/conversation-list.js index 1411815..8879b1a 100644 --- a/public/components/conversation-list.js +++ b/public/components/conversation-list.js @@ -15,25 +15,25 @@ class ConversationList extends HTMLElement { this.shadowRoot.innerHTML = ` @@ -62,7 +62,10 @@ class ConversationList extends HTMLElement {
diff --git a/public/components/conversations-crud.js b/public/components/conversations-crud.js index 0919d51..6877f4a 100644 --- a/public/components/conversations-crud.js +++ b/public/components/conversations-crud.js @@ -68,11 +68,9 @@ class ConversationsCrud extends HTMLElement {
diff --git a/public/components/home-dashboard.js b/public/components/home-dashboard.js index e8d09de..c29ab58 100644 --- a/public/components/home-dashboard.js +++ b/public/components/home-dashboard.js @@ -21,18 +21,16 @@ class HomeDashboard extends HTMLElement { this.shadowRoot.innerHTML = `
@@ -197,14 +200,14 @@ class RunTimeline extends HTMLElement { addedOptimistic = true; } - // auto-scroll al final cuando hay mensajes nuevos - // Solo si el usuario estaba cerca del final (dentro de 150px) o si había optimistas - const wasNearBottom = this._lastScrollPosition === undefined || - (log.scrollHeight - this._lastScrollPosition - log.clientHeight) < 150; - if (addedOptimistic || wasNearBottom) { + // Auto-scroll al final cuando hay mensajes nuevos. + // Si el usuario scrolleó arriba (>150px del fondo), respetamos su posición + // a menos que él mismo haya disparado un optimistic bubble. + if (addedOptimistic || !this._userScrolledUp) { log.scrollTop = log.scrollHeight; + // Una vez forzado al final, considerarlo "abajo" hasta que vuelva a scrollear arriba. + this._userScrolledUp = false; } - this._lastScrollPosition = log.scrollTop; requestAnimationFrame(() => this.emitLayout()); this.bindScroll(log); @@ -215,8 +218,14 @@ class RunTimeline extends HTMLElement { if (this._scrollBound) return; this._scrollBound = true; log.addEventListener("scroll", () => { - this._lastScrollPosition = log.scrollTop; - emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop }); + // Throttle con rAF: el handler real corre 1x por frame. + if (this._scrollRaf) return; + this._scrollRaf = requestAnimationFrame(() => { + this._scrollRaf = null; + const distFromBottom = log.scrollHeight - log.scrollTop - log.clientHeight; + this._userScrolledUp = distFromBottom > 150; + emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop, userScrolledUp: this._userScrolledUp }); + }); }); } @@ -293,12 +302,11 @@ class RunTimeline extends HTMLElement { bubble.appendChild(metaEl); log.appendChild(bubble); - - // Solo hacer scroll si el usuario ya estaba cerca del final (dentro de 100px) - const wasNearBottom = (log.scrollHeight - log.scrollTop - log.clientHeight) < 100; - if (wasNearBottom) { - log.scrollTop = log.scrollHeight; - } + + // El usuario acaba de mandar un mensaje: forzamos al final y reseteamos + // el flag "scrolled-up" así los próximos mensajes del bot también auto-scrollean. + log.scrollTop = log.scrollHeight; + this._userScrolledUp = false; // Emit layout update requestAnimationFrame(() => this.emitLayout()); diff --git a/public/index.html b/public/index.html index 03cc8e9..b68c8b1 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,7 @@ Bot Ops Console + diff --git a/public/lib/api.js b/public/lib/api.js index 3ce6583..495cf90 100644 --- a/public/lib/api.js +++ b/public/lib/api.js @@ -1,3 +1,35 @@ +import { toast } from "./toast.js"; + +/** + * fetch wrapper que dispara toast en error de red o respuesta no-OK. + * Devuelve la respuesta parseada como JSON. Si la respuesta tiene + * `{ ok: false, error }`, también dispara toast. + */ +export async function safeFetch(url, opts = {}, { silent = false, label = null } = {}) { + let res; + try { + res = await fetch(url, opts); + } catch (err) { + if (!silent) toast({ kind: "error", text: `${label || "Red"}: ${err?.message || "sin conexión"}` }); + throw err; + } + if (!res.ok) { + let body = null; + try { body = await res.json(); } catch (_) {} + const msg = body?.error || body?.message || `${res.status} ${res.statusText}`; + if (!silent) toast({ kind: "error", text: `${label || "Error"}: ${msg}` }); + const err = new Error(msg); + err.status = res.status; + err.body = body; + throw err; + } + try { + return await res.json(); + } catch (_) { + return {}; + } +} + export const api = { async conversations({ q = "", status = "", state = "" } = {}) { const u = new URL("/conversations", location.origin); @@ -46,16 +78,16 @@ export const api = { }, async simEvolution(payload) { - return fetch("/webhook/evolution", { + return safeFetch("/webhook/evolution", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), - }).then(r => r.json()); + }, { label: "Sim Evolution" }); }, async retryLast(chat_id) { if (!chat_id) throw new Error("chat_id_required"); - return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json()); + return safeFetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }, { label: "Retry" }); }, // Products CRUD diff --git a/public/lib/sse.js b/public/lib/sse.js index f1e5309..cca7e55 100644 --- a/public/lib/sse.js +++ b/public/lib/sse.js @@ -1,15 +1,71 @@ import { emit } from "./bus.js"; -export function connectSSE() { - const es = new EventSource("/stream"); +/** + * SSE client con reconnect exponencial y try-catch en parseo. + * Si el server reinicia o un evento viene malformado, no rompe la app. + */ - es.addEventListener("hello", () => emit("sse:status", { ok: true })); - es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data))); - es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data))); - es.addEventListener("takeover.created", (e) => emit("takeover:created", JSON.parse(e.data))); - es.addEventListener("order.created", (e) => emit("order:created", JSON.parse(e.data))); +let _es = null; +let _retryDelay = 1000; +let _retryTimer = null; +const MAX_RETRY = 30_000; - es.onerror = () => emit("sse:status", { ok: false }); +const EVENTS = [ + ["conversation.upsert", "conversation:upsert"], + ["run.created", "run:created"], + ["takeover.created", "takeover:created"], + ["order.created", "order:created"], +]; - return es; +function safeParse(rawData, evName) { + try { + return JSON.parse(rawData); + } catch (err) { + console.error(`[sse] bad payload for ${evName}:`, err?.message || err); + return null; + } +} + +function attach(es) { + es.addEventListener("hello", () => { + _retryDelay = 1000; // reset on success + emit("sse:status", { ok: true }); + }); + + for (const [serverName, busName] of EVENTS) { + es.addEventListener(serverName, (e) => { + const data = safeParse(e.data, serverName); + if (data !== null) emit(busName, data); + }); + } + + es.onerror = () => { + emit("sse:status", { ok: false }); + try { es.close(); } catch (_) {} + if (_es === es) _es = null; + scheduleReconnect(); + }; +} + +function scheduleReconnect() { + if (_retryTimer) return; + const delay = _retryDelay; + _retryTimer = setTimeout(() => { + _retryTimer = null; + connectSSE(); + }, delay); + _retryDelay = Math.min(_retryDelay * 2, MAX_RETRY); +} + +export function connectSSE() { + if (_retryTimer) { + clearTimeout(_retryTimer); + _retryTimer = null; + } + if (_es) { + try { _es.close(); } catch (_) {} + } + _es = new EventSource("/stream"); + attach(_es); + return _es; } diff --git a/public/lib/toast.js b/public/lib/toast.js new file mode 100644 index 0000000..3b4e61e --- /dev/null +++ b/public/lib/toast.js @@ -0,0 +1,81 @@ +/** + * Toast service global. Sin dependencias. + * Inyecta un container en y empuja toasts apilados. + * + * Uso: + * import { toast } from "./toast.js"; + * toast({ kind: "error", text: "No se pudo guardar" }); + * toast({ kind: "ok", text: "Listo", ms: 2000 }); + */ + +const KIND_COLORS = { + error: { bg: "#241214", border: "#e74c3c", text: "#ffe9ea" }, + ok: { bg: "#0f2a1a", border: "#1f6f43", text: "#cdebd8" }, + warn: { bg: "#2a1f0a", border: "#F59E0B", text: "#ffe6b0" }, + info: { bg: "#0f2030", border: "#1f6feb", text: "#cce0ff" }, +}; + +let _container = null; + +function ensureContainer() { + if (_container) return _container; + _container = document.createElement("div"); + _container.id = "toast-stack"; + Object.assign(_container.style, { + position: "fixed", + right: "16px", + bottom: "16px", + display: "flex", + flexDirection: "column", + gap: "8px", + zIndex: "9999", + pointerEvents: "none", + maxWidth: "420px", + }); + document.body.appendChild(_container); + return _container; +} + +export function toast({ kind = "error", text = "", ms = 4000 } = {}) { + if (!text) return; + const colors = KIND_COLORS[kind] || KIND_COLORS.info; + const el = document.createElement("div"); + Object.assign(el.style, { + background: colors.bg, + border: `1px solid ${colors.border}`, + color: colors.text, + padding: "10px 12px", + borderRadius: "10px", + fontSize: "13px", + boxShadow: "0 4px 12px rgba(0,0,0,.4)", + pointerEvents: "auto", + cursor: "pointer", + transform: "translateX(120%)", + transition: "transform .25s ease, opacity .25s ease", + opacity: "0", + wordBreak: "break-word", + overflowWrap: "anywhere", + }); + el.textContent = String(text); + + const c = ensureContainer(); + c.appendChild(el); + + // Animar entrada + requestAnimationFrame(() => { + el.style.transform = "translateX(0)"; + el.style.opacity = "1"; + }); + + const dismiss = () => { + el.style.transform = "translateX(120%)"; + el.style.opacity = "0"; + setTimeout(() => el.remove(), 280); + }; + el.addEventListener("click", dismiss); + setTimeout(dismiss, Math.max(800, ms)); +} + +export function toastError(text) { toast({ kind: "error", text }); } +export function toastOk(text) { toast({ kind: "ok", text, ms: 2500 }); } +export function toastWarn(text) { toast({ kind: "warn", text }); } diff --git a/public/main.js b/public/main.js index f9125ff..847b376 100644 --- a/public/main.js +++ b/public/main.js @@ -81,7 +81,7 @@ async function processMessage({ chat_id, from, text }) { // Minimal simulated LLM output (replace later) const plan = { reply: `Recibido: "${text}". ¿Querés retiro o envío?`, - next_state: "BUILDING_ORDER", + next_state: "CART", intent: "create_order", missing_fields: ["delivery_or_pickup"], order_action: "none", diff --git a/public/styles/theme.css b/public/styles/theme.css new file mode 100644 index 0000000..d7e6e10 --- /dev/null +++ b/public/styles/theme.css @@ -0,0 +1,74 @@ +/** + * Tema global (CSS custom properties). + * + * Las custom properties heredan a través del shadow DOM, así que cualquier + * componente puede usar `var(--bg)` sin re-declarar nada. + */ +:root { + /* Surfaces */ + --bg: #0b0f14; + --panel: #121823; + --panel-2: #0f1520; + --panel-3: #0a0e15; + + /* Borders */ + --border: #1e2a3a; + --border-hi: #253245; + --border-active:#1f6feb; + + /* Text */ + --text: #e7eef7; + --text-muted: #8aa0b5; + --text-dim: #d7e2ef; + --text-on-acc: #ffffff; + + /* Accents */ + --accent: #1f6feb; + --accent-hover: #2570f0; + --ok: #25D366; + --warn: #F59E0B; + --err: #e74c3c; + + /* Bubbles */ + --user-bubble: #0f2a1a; + --user-border: #1f6f43; + --user-meta: #b9d9c6; + --user-name: #cdebd8; + + --bot-bubble: #111b2a; + --bot-border: #2a3a55; + --bot-meta: #a9bed6; + --bot-name: #c7d8ee; + + --err-bubble: #241214; + --err-border: #e74c3c; + --err-meta: #ffd0d4; + --err-name: #ffd0d4; + + /* Charts (alias para componentes que aún usan literales) */ + --chart-blue: #3b82f6; + --chart-green: #25D366; + --chart-purple: #8B5CF6; + --chart-orange: #F59E0B; + --chart-pink: #EC4899; + --chart-gray: #9CA3AF; + + /* Radii / shadows */ + --r-sm: 6px; + --r-md: 8px; + --r-lg: 10px; + --r-xl: 14px; + --shadow-bubble: 0 1px 0 rgba(0,0,0,.35); +} + +/* Compat: alias antiguo `--muted` que ya usa ops-shell.js. */ +:root { + --muted: var(--text-muted); +} + +html, body { + background: var(--bg); + color: var(--text); + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; +}