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; } +}