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>