feat(studio): rediseño de la pantalla del agente — estado arriba, tres zonas, probar al lado

La pantalla estaba organizada por tabla de base de datos: siete pestañas que
eran nuestras siete entidades —documentos, herramientas, canales, conexiones,
eventos— y que se desbordaban a lo ancho. Un dueño de negocio nunca piensa "voy
a Conexiones": piensa "¿por qué contestó mal?" o "quiero que sepa mis precios
nuevos".

Tres cambios.

Un estado arriba que responde "¿está bien mi asistente?" sin un clic:
"Atendiendo en WhatsApp · Sabe de 2 cosas · 1 conversación", y en ámbar cuando
algo falta — "No está atendiendo en ningún lado", "Todavía no sabe nada de tu
negocio". Antes eso había que deducirlo entrando pestaña por pestaña. Cada
señal es un botón que lleva a donde se arregla, y si el destino está en
Avanzado, lo despliega.

Tres zonas en lugar de siete pestañas: Conversaciones, Lo que sabe, Dónde
atiende. Herramientas, Cuentas conectadas y Problemas pasan a "Avanzado",
plegado. No se sacan —hacen falta— pero dejan de competir todos los días con lo
que se mira todos los días.

Y el conocimiento con la prueba lado a lado. El bucle real es leer lo que sabe
→ probar → corregir → probar de nuevo; en dos pestañas separadas eso eran seis
clics por corrección. Desde 1024px van en dos columnas, con el chat fijo al
hacer scroll; abajo de eso se apilan, que es el orden en que igual se trabaja.

El vocabulario deja de ser el nuestro: "Base de conocimiento" es "Lo que sabe",
"Auditoría" es "Problemas", "Conexiones" es "Cuentas conectadas". Y la site_key
sale del encabezado: se usa una vez, al instalar el widget, y vive donde se
instala.

No se tocó ni el color ni la tipografía ni Umi. Esto es estructura, no pintura:
cambiando las dos cosas a la vez no se sabría cuál mejoró qué.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-22 11:11:53 -05:00
co-authored by Claude Opus 5
parent c78fcce89b
commit 22158a10a7
7 changed files with 220 additions and 62 deletions
+87 -33
View File
@@ -11,6 +11,7 @@ import TabChat from './agente/TabChat.vue'
import TabConversaciones from './agente/TabConversaciones.vue'
import TabHerramientas from './agente/TabHerramientas.vue'
import TabCanales from './agente/TabCanales.vue'
import EstadoAgente from './agente/EstadoAgente.vue'
const props = defineProps({
tenantId: { type: String, required: true },
@@ -234,27 +235,36 @@ async function cargarConexiones() {
conexiones.value = r.items || []
}
const tabs = computed(() =>
contexto.esPortal
? [
['conversaciones', 'Conversaciones'],
['chat', 'Probar'],
['conocimiento', 'Base de conocimiento'],
['herramientas', 'Herramientas'],
['canales', 'Canales'],
['conexiones', 'Conexiones'],
['auditoria', 'Auditoría'],
]
: [
['conocimiento', 'Base de conocimiento'],
['herramientas', 'Herramientas'],
['canales', 'Canales'],
['conexiones', 'Conexiones'],
['chat', 'Chat de prueba'],
['conversaciones', 'Conversaciones'],
['auditoria', 'Auditoría'],
],
)
// Tres zonas en vez de siete pestañas. Las siete eran nuestras siete tablas
// —documentos, tools, canales, conexiones, eventos— y un dueño de negocio no
// piensa "voy a Conexiones": piensa "¿por qué contestó mal?" o "quiero que
// sepa mis precios nuevos".
//
// Lo que se usa todos los días queda arriba; lo que se toca una vez y se
// olvida, en Avanzado.
const ZONAS = [
['conversaciones', 'Conversaciones'],
['conocimiento', 'Lo que sabe'],
['canales', 'Dónde atiende'],
]
const AVANZADO = [
['herramientas', 'Herramientas'],
['conexiones', 'Cuentas conectadas'],
['auditoria', 'Problemas'],
]
const mostrarAvanzado = ref(false)
// Si se entra directo a una pestaña avanzada (por la URL o desde el estado de
// arriba), la sección tiene que verse abierta o el botón activo queda
// escondido detrás de un "Avanzado" cerrado.
if (AVANZADO.some(([k]) => k === tab.value)) mostrarAvanzado.value = true
function irA(destino) {
tab.value = destino
if (AVANZADO.some(([k]) => k === destino)) mostrarAvanzado.value = true
}
// ─── Auditoría ────────────────────────────────────────────────────────────────
const eventos = ref([])
@@ -294,13 +304,8 @@ watch(
<div>
<router-link :to="`/tenants/${tenantId}`" class="label hover:text-brand">&larr; Agentes</router-link>
<div v-if="agente" class="mb-6 mt-1 flex items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="text-xl font-semibold text-texto">{{ agente.nombre }}</h1>
<p class="text-xs text-tenue mt-1">
site_key: <code class="bg-elevado px-1.5 py-0.5 rounded">{{ agente.site_key }}</code>
</p>
</div>
<div v-if="agente" class="mb-4 mt-1 flex items-start justify-between gap-4">
<h1 class="text-xl font-semibold text-texto min-w-0 truncate">{{ agente.nombre }}</h1>
<!-- Un archivo que se reenvía sirve para justificar el gasto puertas
adentro; un panel al que hay que entrar, no. -->
<a :href="urlReporte" class="btn-ghost shrink-0" title="Conversaciones y consumo del mes en Excel">
@@ -308,13 +313,24 @@ watch(
</a>
</div>
<!-- El estado va antes que la navegación: es lo primero que se quiere
saber y, además, lleva directo a lo que haya que arreglar. -->
<EstadoAgente
v-if="agente"
:canales="canales"
:documentos="documentos"
:sesiones="sesiones"
:eventos="eventos"
@ir="irA"
/>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div class="flex gap-1.5 mb-6 overflow-x-auto sin-barra pb-1">
<div class="flex items-center gap-1.5 mb-6 flex-wrap">
<button
v-for="[key, label] in tabs"
v-for="[key, label] in ZONAS"
:key="key"
class="px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
class="px-3.5 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
:class="tab === key
? 'bg-brand text-white font-medium'
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-borde hover:border-brand/50'"
@@ -322,10 +338,38 @@ watch(
>
{{ label }}
</button>
<!-- Lo que se configura una vez no puede competir todos los días con lo
que se mira todos los días. -->
<button
class="ml-auto text-xs text-tenue hover:text-texto inline-flex items-center gap-1"
@click="mostrarAvanzado = !mostrarAvanzado"
>
Avanzado
<span class="text-[10px]">{{ mostrarAvanzado ? '▲' : '▼' }}</span>
</button>
</div>
<!-- Base de conocimiento -->
<div v-if="tab === 'conocimiento'">
<div v-if="mostrarAvanzado" class="flex gap-1.5 mb-6 flex-wrap">
<button
v-for="[key, label] in AVANZADO"
:key="key"
class="px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors"
:class="tab === key
? 'bg-texto text-white dark:bg-gray-200 dark:text-gray-900 font-medium'
: 'bg-white dark:bg-gray-900 text-tenue border border-borde hover:border-brand/50'"
@click="tab = key"
>
{{ label }}
</button>
</div>
<!-- Lo que sabe: el conocimiento y la prueba, lado a lado.
El bucle real es leer lo que sabe probar corregir probar de
nuevo. Con las dos cosas en pestañas separadas, eso eran seis clics
por corrección; acá es escribir y ver. -->
<div v-if="tab === 'conocimiento'" class="grid gap-4 lg:grid-cols-[1fr_21rem] items-start">
<div>
<div class="card p-4 mb-4">
<div class="flex gap-1 mb-3">
<button
@@ -432,6 +476,16 @@ watch(
</div>
</div>
</div>
<!-- La prueba, al lado y siempre visible mientras se edita el
conocimiento. En pantallas angostas cae abajo, que es el orden en
que igual se trabaja. -->
<div class="lg:sticky lg:top-4">
<p class="label mb-2">Probalo</p>
<TabChat :agente-id="agenteIdNum" />
</div>
<!-- Editar una nota o el texto extraído de un archivo -->
<div
v-if="editandoDoc"
@@ -0,0 +1,104 @@
<script setup>
import { computed } from 'vue'
import UiIcono from '../../components/ui/UiIcono.vue'
// Responde "¿está bien mi asistente?" sin hacer un solo clic.
//
// Antes había que entrar pestaña por pestaña para saberlo: si estaba atendiendo
// en algún lado, si sabía algo, si alguien le había escrito. Tres datos que el
// dueño quiere de un vistazo y que estaban repartidos en tres pantallas.
const props = defineProps({
canales: { type: Array, default: () => [] },
documentos: { type: Array, default: () => [] },
sesiones: { type: Array, default: () => [] },
eventos: { type: Array, default: () => [] },
})
const emit = defineEmits(['ir'])
const canalesActivos = computed(() => props.canales.filter((c) => c.activo))
// Los tipos vienen en minúscula del backend; "Atendiendo en whatsapp" se lee
// como un error de tipeo.
const NOMBRES_CANAL = { whatsapp: 'WhatsApp', telegram: 'Telegram', web: 'tu web' }
const nombreCanal = (t) => NOMBRES_CANAL[t] || t
const fuentesListas = computed(() => props.documentos.filter((d) => d.estado === 'listo'))
// Dos meses es cuando una fuente empieza a ser sospechosa de estar vieja: es el
// mismo corte que usa la lista de conocimiento, para que no digan cosas
// distintas.
const fuentesViejas = computed(() =>
fuentesListas.value.filter(
(d) => d.procesado_at && Date.now() - new Date(d.procesado_at) > 60 * 86400000,
),
)
const problemas = computed(() => props.eventos.filter((e) => e.nivel === 'error').length)
// Cada señal dice qué pasa y, cuando algo anda mal, a dónde ir a arreglarlo.
const señales = computed(() => [
{
clave: 'canales',
icono: 'mensaje',
bien: canalesActivos.value.length > 0,
texto: canalesActivos.value.length
? `Atendiendo en ${canalesActivos.value.map((c) => nombreCanal(c.tipo)).join(' y ')}`
: 'No está atendiendo en ningún lado',
destino: 'canales',
},
{
clave: 'conocimiento',
icono: 'escribir',
bien: fuentesListas.value.length > 0 && fuentesViejas.value.length === 0,
texto: !fuentesListas.value.length
? 'Todavía no sabe nada de tu negocio'
: fuentesViejas.value.length
? `${fuentesViejas.value.length} de ${fuentesListas.value.length} fuentes están viejas`
: `Sabe de ${fuentesListas.value.length} ${fuentesListas.value.length === 1 ? 'cosa' : 'cosas'}`,
destino: 'conocimiento',
},
{
clave: 'conversaciones',
icono: 'grafico',
// Que nadie haya escrito todavía no es un problema: es un dato.
bien: true,
neutro: props.sesiones.length === 0,
texto: props.sesiones.length
? `${props.sesiones.length} ${props.sesiones.length === 1 ? 'conversación' : 'conversaciones'}`
: 'Nadie escribió todavía',
destino: 'conversaciones',
},
])
</script>
<template>
<div class="card p-1 mb-6 flex flex-wrap">
<button
v-for="s in señales"
:key="s.clave"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors text-left"
@click="emit('ir', s.destino)"
>
<UiIcono
:nombre="s.bien ? s.icono : 'alerta'"
:tam="16"
:class="s.neutro ? 'text-tenue' : s.bien ? 'text-brand' : 'text-amber-600 dark:text-amber-400'"
/>
<span :class="s.neutro ? 'text-tenue' : s.bien ? 'text-texto' : 'text-amber-700 dark:text-amber-400 font-medium'">
{{ s.texto }}
</span>
</button>
<button
v-if="problemas"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors ml-auto"
@click="emit('ir', 'auditoria')"
>
<UiIcono nombre="alerta" :tam="16" class="text-red-600 dark:text-red-400" />
<span class="text-red-700 dark:text-red-400 font-medium">
{{ problemas }} {{ problemas === 1 ? 'problema' : 'problemas' }}
</span>
</button>
</div>
</template>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-Du2k2-WV.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-C4iBAJs_.css">
<script type="module" crossorigin src="/orchestrator/assets/index-CxArDUWK.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-Cd5jgLRW.css">
</head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la