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
156 changes: 146 additions & 10 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -320,6 +321,62 @@ function Sala({
/** Agentes cuyo detalle de tools está expandido (click en la línea). */
const [toolsAbiertas, setToolsAbiertas] = useState<Record<string, boolean>>({});
const [agents, setAgents] = useState<Agent[]>([]);
/**
* 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<Set<string>>(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<string, { nombre: string; color: string; agente: boolean }>();
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<OrphanTurn[]>([]);
/** Query del menú de menciones (null = cerrado). */
const [mention, setMention] = useState<string | null>(null);
Expand Down Expand Up @@ -900,6 +957,17 @@ function Sala({
{/* Los agentes de la sala, como jugadores visibles */}
<AgentList agents={agents} />

{/* 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 && (
<FiltroChat
gente={filtrables}
filtro={filtro}
onAlternar={alternarFiltro}
onLimpiar={() => setFiltro(new Set())}
/>
)}

{/* Trabajo que quedó a medias por un crash: decide el humano */}
{orphans.length > 0 && (
<div className="orphan-card">
Expand All @@ -917,16 +985,36 @@ function Sala({
</div>
)}

{/* 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 && (
<div className="filtro-aviso">
<span>{t.mensajesOcultos(ocultos)}</span>
<button type="button" onClick={() => setFiltro(new Set())}>
{t.verTodo}
</button>
</div>
)}

<div className="chat-scroll">
{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.
<ChatRow key={i} msg={m} seguido={esSeguido(messages, i)} roomId={roomId!} />
//
// `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.
<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 }).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 (
Expand Down Expand Up @@ -1105,15 +1193,44 @@ function Sala({
>
{inspect ? t.seleccionando : t.seleccionarBtn}
</button>
{/* 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. */}
<div className="presencia">
{members.map((m) => (
<Avatar
<button
key={m.socketId}
foto={m.foto}
color={m.color}
inicial={m.name.slice(0, 1).toUpperCase()}
titulo={m.name}
/>
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)}
>
<Avatar
foto={m.foto}
color={m.color}
inicial={m.name.slice(0, 1).toUpperCase()}
/>
</button>
))}
{/* 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) => (
<button
key={a.id}
type="button"
className={`filtro-btn ${filtro.has(a.name) ? "on" : ""} ${
filtro.size > 0 && !filtro.has(a.name) ? "off" : ""
}`}
style={filtro.has(a.name) ? { borderColor: a.color } : undefined}
onClick={() => alternarFiltro(a.name)}
title={filtro.has(a.name) ? t.quitarDelFiltro(a.name) : t.filtrarPor(a.name)}
>
<Avatar color={a.color} inicial="AI" textoOscuro />
</button>
))}
<CuentaPanel
usuario={cuenta.usuario}
Expand Down Expand Up @@ -1333,6 +1450,25 @@ function IconoDeVista({ vista }: { vista: Vista }) {
);
}

/**
* El color con el que el server marca un mensaje de sistema que ES un fallo.
*
* Los mensajes de sistema no son todos iguales: "entró agente-1 a la sala" es
* ruido, y "agente-2: se acabó el crédito" es algo que si no ves, te quedas
* esperando sin saber por qué nada pasa. El server ya los separa por color al
* emitirlos, así que el filtro se apoya en eso en vez de leer el texto.
*/
const COLOR_FALLO = "#d95d63";

/** ¿Este mensaje se ve con el filtro puesto? */
function pasaElFiltro(msg: ChatMessage, filtro: Set<string>): 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];
Expand Down
118 changes: 118 additions & 0 deletions web/src/FiltroChat.tsx
Original file line number Diff line number Diff line change
@@ -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<string>;
onAlternar: (nombre: string) => void;
onLimpiar: () => void;
}) {
const { t } = useTextos();
const [abierto, setAbierto] = useState(false);
const caja = useRef<HTMLDivElement | null>(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 (
<div className="filtro-caja" ref={caja}>
<button
type="button"
className={`filtro-abrir ${activos > 0 ? "on" : ""}`}
onClick={() => setAbierto((v) => !v)}
title={t.filtrarChat}
>
{activos > 0 ? t.filtrandoA(activos) : t.filtrarChat}
</button>

{abierto && (
<div className="filtro-menu">
{props.gente.map((g) => (
<Fila
key={g.nombre}
nombre={g.nombre}
color={g.color}
agente={g.agente}
marcado={props.filtro.has(g.nombre)}
onClick={() => props.onAlternar(g.nombre)}
/>
))}
{activos > 0 && (
<button
type="button"
className="filtro-limpiar"
onClick={() => {
props.onLimpiar();
setAbierto(false);
}}
>
{t.verTodo}
</button>
)}
</div>
)}
</div>
);
}

function Fila(props: {
nombre: string;
color: string;
agente?: boolean;
marcado: boolean;
onClick: () => void;
}) {
const { t } = useTextos();
return (
<button
type="button"
className={`filtro-fila ${props.marcado ? "on" : ""}`}
onClick={props.onClick}
>
<span className="filtro-punto" style={{ background: props.color }} />
<span className="filtro-nombre" style={{ color: props.color }}>
{props.nombre}
</span>
{props.agente && <span className="tag-ai">{t.tagAgente}</span>}
<span className="filtro-check">{props.marcado ? "✓" : ""}</span>
</button>
);
}
19 changes: 19 additions & 0 deletions web/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading