Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions web/src/AgentList.tsx
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -46,30 +47,38 @@ export function AgentList({ agents }: { agents: Agent[] }) {
}

function AgentRow({ agent }: { agent: Agent }) {
const { t } = useTextos();
return (
<div className={`agent-row agent-${agent.state}`}>
<span className="agent-dot" style={{ background: agent.color }} />
<span className="agent-name" style={{ color: agent.color }}>
{agent.name}
</span>
<span className="agent-status">{statusText(agent)}</span>
<span className="agent-status">{textoDeEstado(agent, t)}</span>
</div>
);
}

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

Expand Down
26 changes: 24 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -1063,7 +1073,13 @@ function Sala({
<ChatRow key={i} msg={m} seguido={esSeguido(mensajesVisibles, i)} roomId={roomId!} />
))}
{/* 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.
Expand Down Expand Up @@ -1092,6 +1108,12 @@ function Sala({
/>
) : null}
{streaming[agentId] && <div className="burbuja">{streaming[agentId]}</div>}
{/* 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 && (
<div className="agente-pensando">{textoDeEstado(agent, t, false)}</div>
)}
</div>
</div>
);
Expand Down
14 changes: 14 additions & 0 deletions web/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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`),
Expand Down
18 changes: 18 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Loading