From ac1592a156203b6f17c5eeb7d7012f4e991b6e1c Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Sun, 13 Sep 2026 22:10:06 -0600 Subject: [PATCH] El agente aparece en el chat aunque todavia no diga nada Le pedias algo a un agente y el chat no cambiaba: ni avatar, ni nombre, ni una linea, como si el mensaje no hubiera llegado. Cuarenta segundos despues aparecia todo de golpe, y en ese hueco la gente lo reenviaba creyendo que se habia perdido. Medido en vivo: durante el silencio, por el socket solo viajaban cursores, y en el log del server la vuelta 1 salia con 19,613 tokens de salida. Escribir un archivo grande tarda eso, porque mientras el modelo arma los argumentos de la herramienta no hay nada que transmitir. Se descarto que fuera el cache del proveedor, el tamano del contexto y la falta de fine-grained tool streaming: escribir 400 lineas desde otro cliente tarda lo mismo, solo que ahi se ve un contador subiendo. O sea, el silencio es real y va a seguir. Lo que faltaba era decirlo. El bloque del chat solo pintaba agentes con streaming o con lineas de herramienta, asi que uno que todavia no emite no existe. Ahora entran tambien los que no estan idle, y mientras no haya nada mas se lee su estado. En cuanto llega el primer evento la linea se va sola, porque su condicion deja de cumplirse. Los textos salen de `statusText`, que ya distinguia los tres estados con sus matices (a quien espera, por que archivo). Se comparte en vez de escribir otros: el mismo hecho contado distinto en dos lugares de la misma pantalla se lee como dos cosas. De paso se mueven al i18n, que estaban en espanol aunque la Sala estuviera en ingles. Cae de rebote el streaming que quedaba pegado al interrumpir un turno: el `finally` marca idle y emite `agents`, asi que el bloque desaparece aunque no llegue el mensaje final. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L1nFf8JaKeGedJ6RaZQYZ8 --- web/src/AgentList.tsx | 23 ++++++++++++++++------- web/src/App.tsx | 26 ++++++++++++++++++++++++-- web/src/i18n.tsx | 14 ++++++++++++++ web/src/styles.css | 18 ++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/web/src/AgentList.tsx b/web/src/AgentList.tsx index acf9aed..80e7b18 100644 --- a/web/src/AgentList.tsx +++ b/web/src/AgentList.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { Agent } from "./socket.js"; +import { useTextos, type Textos } from "./i18n.js"; /** * Lista de agentes de la sala — tres estados visualmente distintos: @@ -46,30 +47,38 @@ export function AgentList({ agents }: { agents: Agent[] }) { } function AgentRow({ agent }: { agent: Agent }) { + const { t } = useTextos(); return (
{agent.name} - {statusText(agent)} + {textoDeEstado(agent, t)}
); } -function statusText(a: Agent): string { +/** + * Qué está haciendo un agente, en una línea. + * + * Se exporta porque el chat pinta lo mismo mientras el agente todavía no emite + * nada: el mismo hecho contado distinto en dos lugares de la misma pantalla se + * lee como dos cosas. + */ +export function textoDeEstado(a: Agent, t: Textos, conTarea = true): string { switch (a.state) { case "working": - return a.task ? truncate(a.task) : "trabajando"; + return conTarea && a.task ? truncate(a.task) : t.estadoTrabajando; case "waiting": // Decir A QUIÉN espera convierte una espera opaca en algo comprensible. return a.waitingFor - ? `esperando a ${a.waitingFor.holder ?? "otro agente"} (${a.waitingFor.path})` - : "esperando"; + ? t.estadoEsperandoA(a.waitingFor.holder ?? t.otroAgente, a.waitingFor.path) + : t.estadoEsperando; case "stuck": - return "atorado — sin avanzar"; + return t.estadoAtorado; default: - return "inactivo"; + return t.estadoInactivo; } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 7efa79f..8180d9c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,7 +13,7 @@ import { type Agent, type OrphanTurn, } from "./socket.js"; -import { AgentList } from "./AgentList.js"; +import { AgentList, textoDeEstado } from "./AgentList.js"; import { FiltroChat } from "./FiltroChat.js"; import { MentionMenu } from "./MentionMenu.js"; import { Historial } from "./Historial.js"; @@ -370,6 +370,16 @@ function Sala({ ); const ocultos = messages.length - mensajesVisibles.length; + /** + * Los agentes que están en algo, como objeto para poder mezclarlos con + * `streaming` y `toolLines` sin duplicar a nadie: las tres fuentes se indexan + * por agentId, así que el spread las deduplica solo. + */ + const trabajando = useMemo( + () => Object.fromEntries(agents.filter((a) => a.state !== "idle").map((a) => [a.id, true])), + [agents], + ); + /** * Quiénes se pueden filtrar: los que están conectados MÁS los que hablaron. * @@ -1063,7 +1073,13 @@ function Sala({ ))} {/* Un bloque de streaming POR AGENTE: varios pueden hablar a la vez */} - {Object.keys({ ...streaming, ...toolLines }) + {/* Los que trabajan entran aunque no hayan emitido NADA todavía. + Escribir un archivo grande son cuarenta segundos en los que el + modelo arma los argumentos de la herramienta y no hay nada que + transmitir: sin esto el chat se ve idéntico a no haber mandado el + mensaje, y la gente lo reenvía creyendo que se perdió. El estado + ya viajaba, solo lo pintaba la lista de arriba. */} + {Object.keys({ ...streaming, ...toolLines, ...trabajando }) // El streaming NO sale de `messages`, así que sin esto un agente // filtrado fuera seguiría apareciendo mientras escribe y el filtro // se vería roto. @@ -1092,6 +1108,12 @@ function Sala({ /> ) : null} {streaming[agentId] &&
{streaming[agentId]}
} + {/* Se va sola en cuanto llega el primer evento: su condición + deja de cumplirse y no hay nada que limpiar. Sin la tarea, + que ya se lee en el mensaje que la pidió. */} + {!toolLines[agentId]?.length && !streaming[agentId] && agent && ( +
{textoDeEstado(agent, t, false)}
+ )} ); diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 00adfc5..90fb38a 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -115,6 +115,14 @@ const TEXTOS = { filtrarChat: "Filtrar", filtrandoA: (n: number) => (n === 1 ? "Filtrando a 1" : `Filtrando a ${n}`), tagAgente: "agente", + // Estado de un agente. Los usan la lista de arriba y el bloque del chat: el + // mismo hecho no debe leerse distinto en dos lugares de la misma pantalla. + estadoTrabajando: "trabajando", + estadoEsperandoA: (quien: string, ruta: string) => `esperando a ${quien} (${ruta})`, + estadoEsperando: "esperando", + otroAgente: "otro agente", + estadoAtorado: "atorado, sin avanzar", + estadoInactivo: "inactivo", filtrarPor: (quien: string) => `Ver solo lo de ${quien}`, quitarDelFiltro: (quien: string) => `Quitar a ${quien} del filtro`, mensajesOcultos: (n: number) => @@ -326,6 +334,12 @@ const TEXTOS = { filtrarChat: "Filter", filtrandoA: (n: number) => (n === 1 ? "Filtering 1" : `Filtering ${n}`), tagAgente: "agent", + estadoTrabajando: "working", + estadoEsperandoA: (quien: string, ruta: string) => `waiting for ${quien} (${ruta})`, + estadoEsperando: "waiting", + otroAgente: "another agent", + estadoAtorado: "stuck, not moving", + estadoInactivo: "idle", filtrarPor: (quien: string) => `Show only ${quien}`, quitarDelFiltro: (quien: string) => `Remove ${quien} from the filter`, mensajesOcultos: (n: number) => (n === 1 ? "1 message hidden" : `${n} messages hidden`), diff --git a/web/src/styles.css b/web/src/styles.css index 441dea1..fb44954 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2518,3 +2518,21 @@ img.av { font-size: 11.5px; text-align: center; } + +/* Lo que dice un agente que todavía no dice nada. + Atenuado y en cursiva: es estado, no contenido, y en cuanto llega la primera + línea de verdad desaparece. Los tres puntos laten para que se distinga de un + mensaje que quedó a medias. */ +.agente-pensando { + font-size: 12.5px; + color: var(--dim); + font-style: italic; +} +.agente-pensando::after { + content: "…"; + animation: pensando-late 1.4s ease-in-out infinite; +} +@keyframes pensando-late { + 0%, 100% { opacity: 0.25; } + 50% { opacity: 1; } +}