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