diff --git a/web/src/App.tsx b/web/src/App.tsx index b2ddb7f..f0a995f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import type { Socket } from "socket.io-client"; import { connectSocket, @@ -14,6 +14,7 @@ import { type OrphanTurn, } from "./socket.js"; import { AgentList } from "./AgentList.js"; +import { FiltroChat } from "./FiltroChat.js"; import { MentionMenu } from "./MentionMenu.js"; import { Historial } from "./Historial.js"; import { BackCanvas, type Endpoint } from "./BackCanvas.js"; @@ -320,6 +321,62 @@ function Sala({ /** Agentes cuyo detalle de tools está expandido (click en la línea). */ const [toolsAbiertas, setToolsAbiertas] = useState>({}); const [agents, setAgents] = useState([]); + /** + * Quiénes están seleccionados en el filtro del chat, por NOMBRE. + * + * Vacío significa sin filtro, que es distinto de "nadie seleccionado": con + * cinco personas hablando, arrancar con el chat en blanco sería peor que el + * caos que esto viene a resolver. + * + * Por nombre y no por id porque es lo único que trae el mensaje: `from` es el + * nombre visible del autor, y coincide con `member.name` y con `agent.name`. + * El costo es que dos personas con el mismo nombre quedan indistinguibles — + * el server no los deduplica. + */ + const [filtro, setFiltro] = useState>(new Set()); + const mensajesVisibles = useMemo( + () => messages.filter((m) => pasaElFiltro(m, filtro)), + [messages, filtro], + ); + const ocultos = messages.length - mensajesVisibles.length; + + /** + * Quiénes se pueden filtrar: los que están conectados MÁS los que hablaron. + * + * No basta con `members`: esa lista es de quien está AHORA, y quien cerró la + * pestaña desaparece de ahí aunque sus mensajes sigan en el chat. Filtrar por + * alguien que ya se fue es justo lo que se quiere poder hacer — es su + * conversación la que hay que poder aislar. + * + * Los que siguen dentro conservan su color real; a los que se fueron se les + * saca del propio mensaje, que ya lo trae. + */ + const filtrables = useMemo(() => { + const vistos = new Map(); + for (const m of members) { + vistos.set(m.name, { nombre: m.name, color: m.color, agente: false }); + } + for (const a of agents) { + vistos.set(a.name, { nombre: a.name, color: a.color, agente: true }); + } + for (const msg of messages) { + if (msg.role === "system") continue; + if (vistos.has(msg.from)) continue; + vistos.set(msg.from, { + nombre: msg.from, + color: msg.color, + agente: msg.role === "agent", + }); + } + return [...vistos.values()]; + }, [members, agents, messages]); + const alternarFiltro = useCallback((nombre: string) => { + setFiltro((prev) => { + const siguiente = new Set(prev); + if (!siguiente.delete(nombre)) siguiente.add(nombre); + return siguiente; + }); + }, []); const [orphans, setOrphans] = useState([]); /** Query del menú de menciones (null = cerrado). */ const [mention, setMention] = useState(null); @@ -900,6 +957,17 @@ function Sala({ {/* Los agentes de la sala, como jugadores visibles */} + {/* El botón dice que se puede filtrar; los avatares de arriba son el + atajo para quien ya lo sabe. Sin él, nada anuncia que existe. */} + {roomId && ( + setFiltro(new Set())} + /> + )} + {/* Trabajo que quedó a medias por un crash: decide el humano */} {orphans.length > 0 && (
@@ -917,16 +985,36 @@ function Sala({
)} + {/* Sin esto el chat filtrado se ve igual que un chat vacío, y no hay + forma de saber que te estás perdiendo algo. */} + {filtro.size > 0 && ( +
+ {t.mensajesOcultos(ocultos)} + +
+ )} +
- {messages.map((m, i) => ( + {mensajesVisibles.map((m, i) => ( // Mensajes seguidos del mismo autor se agrupan sin repetir avatar // ni nombre (patrón Discord): el chat respira y se lee como // conversación, no como lista de tarjetas. // Sin sala no hay mensajes que pintar, así que aquí siempre lo hay. - + // + // `esSeguido` recibe el array YA FILTRADO a propósito: con el array + // completo, dos mensajes que quedan contiguos tras filtrar se + // pintarían como no-seguidos y repetirían avatar y nombre. + ))} {/* Un bloque de streaming POR AGENTE: varios pueden hablar a la vez */} - {Object.keys({ ...streaming, ...toolLines }).map((agentId) => { + {Object.keys({ ...streaming, ...toolLines }) + // 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. + .filter((agentId) => filtro.size === 0 || filtro.has(agentId)) + .map((agentId) => { const agent = agents.find((a) => a.id === agentId); const color = agent?.color ?? "#ffc37a"; return ( @@ -1105,15 +1193,44 @@ function Sala({ > {inspect ? t.seleccionando : t.seleccionarBtn} + {/* Los mismos avatares que ya decían quién está, ahora también filtran + el chat. Se reutilizan en vez de meter una fila de chips aparte: + en una columna de 340px cada control nuevo se paga caro, y aquí el + nombre y el color ya están en pantalla. */}
{members.map((m) => ( - + type="button" + className={`filtro-btn ${filtro.has(m.name) ? "on" : ""} ${ + filtro.size > 0 && !filtro.has(m.name) ? "off" : "" + }`} + style={filtro.has(m.name) ? { borderColor: m.color } : undefined} + onClick={() => alternarFiltro(m.name)} + title={filtro.has(m.name) ? t.quitarDelFiltro(m.name) : t.filtrarPor(m.name)} + > + + + ))} + {/* Los agentes también filtran: en una sala con tres, saber cuál + dijo qué es justo lo que se pierde en el ruido. */} + {agents.map((a) => ( + ))} ): boolean { + if (filtro.size === 0) return true; + // Los fallos se ven siempre: perderse uno deja a alguien esperando a un agente + // que ya se murió. + if (msg.role === "system") return msg.color === COLOR_FALLO; + return filtro.has(msg.from); +} + function esSeguido(msgs: ChatMessage[], i: number): boolean { if (i === 0) return false; const prev = msgs[i - 1]; diff --git a/web/src/FiltroChat.tsx b/web/src/FiltroChat.tsx new file mode 100644 index 0000000..8b149e0 --- /dev/null +++ b/web/src/FiltroChat.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from "react"; +import { useTextos } from "./i18n.js"; + +/** Alguien a quien se le puede filtrar: está en la sala o habló alguna vez. */ +export interface Filtrable { + nombre: string; + color: string; + agente: boolean; +} + +/** + * El botón que abre el filtro del chat. + * + * Existe además de los avatares clicables de arriba, y no en vez de ellos: los + * avatares son un atajo para quien ya sabe que se puede filtrar, pero nada en + * ellos lo anuncia. Con cinco personas hablando, el filtro no sirve de nada si + * hay que descubrirlo por accidente. + * + * Y escala mejor: con tres agentes y cinco personas la fila de avatares se + * llena, mientras que una lista con nombres se lee igual de bien. + */ +export function FiltroChat(props: { + gente: Filtrable[]; + filtro: Set; + onAlternar: (nombre: string) => void; + onLimpiar: () => void; +}) { + const { t } = useTextos(); + const [abierto, setAbierto] = useState(false); + const caja = useRef(null); + + // Cerrar al clicar fuera y con Escape: un desplegable que se queda abierto + // tapa el chat, que es justo lo que se vino a leer. + useEffect(() => { + if (!abierto) return; + const fuera = (e: MouseEvent) => { + if (caja.current && !caja.current.contains(e.target as Node)) setAbierto(false); + }; + const tecla = (e: KeyboardEvent) => { + if (e.key === "Escape") setAbierto(false); + }; + document.addEventListener("mousedown", fuera); + document.addEventListener("keydown", tecla); + return () => { + document.removeEventListener("mousedown", fuera); + document.removeEventListener("keydown", tecla); + }; + }, [abierto]); + + // Sin nadie que haya hablado no hay nada que filtrar. + if (props.gente.length === 0) return null; + + const activos = props.filtro.size; + + return ( +
+ + + {abierto && ( +
+ {props.gente.map((g) => ( + props.onAlternar(g.nombre)} + /> + ))} + {activos > 0 && ( + + )} +
+ )} +
+ ); +} + +function Fila(props: { + nombre: string; + color: string; + agente?: boolean; + marcado: boolean; + onClick: () => void; +}) { + const { t } = useTextos(); + return ( + + ); +} diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 811dc19..56f7f1e 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -106,6 +106,16 @@ const TEXTOS = { // Presentar ocultarChat: "Ocultar el chat", + + // Filtro del chat + filtrarChat: "Filtrar", + filtrandoA: (n: number) => (n === 1 ? "Filtrando a 1" : `Filtrando a ${n}`), + tagAgente: "agente", + filtrarPor: (quien: string) => `Ver solo lo de ${quien}`, + quitarDelFiltro: (quien: string) => `Quitar a ${quien} del filtro`, + mensajesOcultos: (n: number) => + n === 1 ? "1 mensaje oculto" : `${n} mensajes ocultos`, + verTodo: "Ver todo", mostrarChat: "Mostrar el chat", // Imágenes @@ -303,6 +313,15 @@ const TEXTOS = { // Presentar ocultarChat: "Hide the chat", + + // Chat filter + filtrarChat: "Filter", + filtrandoA: (n: number) => (n === 1 ? "Filtering 1" : `Filtering ${n}`), + tagAgente: "agent", + 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`), + verTodo: "Show all", mostrarChat: "Show the chat", // Imágenes diff --git a/web/src/styles.css b/web/src/styles.css index eb76403..75cf764 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -922,6 +922,149 @@ img.av { height: 28px; border: 2px solid var(--fondo); } + +/* Los avatares de la presencia filtran el chat al clicarlos. + El botón no se ve: es solo el área clicable alrededor del avatar que ya + estaba ahí, para no meter un control nuevo en una columna estrecha. */ +.filtro-btn { + padding: 0; + border: 2px solid transparent; + border-radius: 50%; + background: transparent; + cursor: pointer; + display: flex; + transition: opacity 0.15s ease, border-color 0.15s ease; +} +.filtro-btn:hover { + opacity: 0.8; +} +/* Seleccionado: el borde toma el color de la persona (va en línea, desde JS). + Los NO seleccionados se atenúan en vez de esconderse: hay que poder ver a + quién falta para sumarlo al filtro. */ +.filtro-btn.off { + opacity: 0.35; +} +.filtro-btn.on .av { + border-color: transparent; +} + +/* El botón que abre el filtro, arriba del chat. Los avatares de la presencia + hacen lo mismo, pero nada en ellos anuncia que se puede: este es el que hace + descubrible la función. */ +.filtro-caja { + position: relative; + padding: 0 16px 8px; +} +.filtro-abrir { + width: 100%; + text-align: left; + background: transparent; + border: 1px solid var(--linea); + border-radius: 3px; + color: var(--dim); + font-size: 11px; + letter-spacing: 1.5px; + text-transform: uppercase; + padding: 7px 10px; + cursor: pointer; +} +.filtro-abrir:hover { + color: var(--texto); + border-color: var(--dim); +} +/* Con filtro puesto el botón deja de ser neutro: es la única pista permanente + de que lo que se está viendo no es todo. */ +.filtro-abrir.on { + color: var(--acento); + border-color: var(--acento); +} + +.filtro-menu { + position: absolute; + left: 16px; + right: 16px; + top: calc(100% - 4px); + background: var(--fondo2); + border: 1px solid var(--linea); + border-radius: 6px; + /* Tope y scroll: la lista crece con la sala (la gente MÁS los agentes), y sin + esto una sala llena taparía el chat que se vino a leer. */ + max-height: 45vh; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + z-index: 30; +} +.filtro-fila { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: transparent; + border: none; + cursor: pointer; + font-size: 12px; + text-align: left; +} +.filtro-fila:hover { + background: var(--panel); +} +.filtro-punto { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.filtro-nombre { + font-weight: 600; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.filtro-check { + color: var(--acento); + font-size: 12px; + width: 12px; +} +.filtro-limpiar { + width: 100%; + background: transparent; + border: none; + border-top: 1px solid var(--linea); + color: var(--dim); + font-size: 11.5px; + padding: 8px 12px; + cursor: pointer; + text-align: left; +} +.filtro-limpiar:hover { + color: var(--texto); +} + +/* Un chat filtrado se ve igual que uno vacío. Esta línea es lo único que + distingue "no hay mensajes" de "los escondiste tú". */ +.filtro-aviso { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 16px; + font-size: 11.5px; + color: var(--dim); + border-bottom: 1px solid var(--linea); +} +.filtro-aviso button { + background: transparent; + border: none; + color: var(--acento); + font-size: 11.5px; + cursor: pointer; + padding: 0; +} +.filtro-aviso button:hover { + text-decoration: underline; +} .invitar { font-size: 10px; letter-spacing: 2px;