From 35093f3f2d41c418ccfef8ccf6a94b5bf248168f Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Wed, 12 Aug 2026 20:02:29 -0600 Subject: [PATCH 1/3] Ponerle nombre a la sala `noche-fiesta-79` no le recuerda a nadie que ahi esta la landing de Cinepolis, y el menu de salas se reordena por ultima visita, asi que la que buscas cambia de lugar cada vez. Ahora la sala se puede nombrar con doble clic en la cabecera. El nombre es de la SALA, no de quien lo puso: viaja en el `joined` y se propaga por socket, asi que los compas lo ven cambiar al momento y en el chat sale quien fue. Un nombre que cambia solo, sin saber por quien, se siente a error. Gana el ultimo que escribe, como el titulo de un documento compartido: es un campo chico, se toca poco, y quien no este de acuerdo lo vuelve a cambiar viendo ya lo del otro. No hace falta bloquear nada. El id NO cambia. Sigue siendo la URL y lo que se dicta por telefono, asi que renombrar no rompe ningun link ya compartido. Sin nombre se ve el id, como siempre; dejarlo vacio lo quita. El menu guarda una copia del nombre en localStorage para pintar la lista sin preguntarle al server por cada sala. Si alguien lo cambio mientras no estabas, lo ves al volver a entrar: para reconocer tus salas, alcanza. Co-Authored-By: Claude Opus 5 --- server/src/index.ts | 26 ++++++++++++++++ server/src/rooms.ts | 36 +++++++++++++++++++++++ server/src/storage/sqlite.ts | 20 ++++++++++++- server/src/storage/types.ts | 11 +++++++ web/src/App.tsx | 57 ++++++++++++++++++++++++++++++++++-- web/src/MenuSalas.tsx | 10 +++++-- web/src/historial-salas.ts | 36 ++++++++++++++++++++++- web/src/i18n.tsx | 2 ++ web/src/socket.ts | 2 ++ web/src/styles.css | 18 ++++++++++++ 10 files changed, 212 insertions(+), 6 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 42be570..2145a3b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -19,6 +19,7 @@ import { ensureRunner, loadRoomIndex, deleteRoom, + renameRoom, type Room, type SelectedElement, } from "./rooms.js"; @@ -339,6 +340,7 @@ fastify.get<{ Params: { id: string } }>("/rooms/:id", async (req, reply) => { if (!room) return reply.code(404).send({ error: "sala no encontrada" }); return { id: room.id, + nombre: room.nombre ?? null, previewUrl: room.preview?.url ?? null, members: membersList(room), }; @@ -431,6 +433,8 @@ io.on("connection", (socket) => { const history = await storage.getMessages(roomId); socket.emit("joined", { roomId, + // Null si nadie la ha nombrado: la Sala muestra el id en ese caso. + nombre: room.nombre ?? null, you: member, members: membersList(room), previewUrl: room.preview?.url ?? null, @@ -700,6 +704,28 @@ io.on("connection", (socket) => { io.to(room.id).emit("history:changed", {}); }); + /** + * Ponerle nombre a la sala. Lo ven todos los que estén dentro. + * + * Se avisa en el chat quién fue: el nombre está en la cabecera de todos, y + * verlo cambiar solo, sin saber por quién, se siente a error. Es el mismo + * trato que se le da a regresar el proyecto o a resolver turnos huérfanos. + */ + socket.on("room:rename", async ({ nombre }: { nombre: unknown }) => { + const room = joinedRoom; + if (!room) return; + const member = room.members.get(socket.id); + const quien = member?.name ?? "alguien"; + + const puesto = await renameRoom(room, nombre); + io.to(room.id).emit("room:renamed", { nombre: puesto }); + systemMsg( + room, + puesto ? `${quien} le puso "${puesto}" a la sala` : `${quien} le quitó el nombre a la sala`, + member?.color, + ); + }); + // El humano decide qué hacer con el trabajo que quedó a medias por un crash. // Nunca se decide automáticamente: ese trabajo ya está en disco y ya lo vieron // todos en el preview (ver DESIGN.md "turnos huérfanos"). diff --git a/server/src/rooms.ts b/server/src/rooms.ts index a34c3f6..7c226b2 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -45,6 +45,11 @@ export interface SelectedElement { /** Estado de una sala en memoria (v1: no persiste; Fase 7 lo lleva a Supabase). */ export interface Room { id: string; + /** + * Cómo le dice la gente a esta sala, o null si nadie la ha nombrado (ahí se + * muestra el id). Es de la sala: lo ven todos los que entren. + */ + nombre?: string | null; workspace: Workspace; preview: Preview | null; /** Hay un arranque de preview en curso (evita dos npm install a la vez). */ @@ -96,6 +101,8 @@ export async function createRoom(): Promise { const room: Room = { id, + // Nace sin nombre: se ve el id hasta que alguien de la sala le ponga uno. + nombre: null, workspace, preview: null, members: new Map(), @@ -144,6 +151,7 @@ async function despertarSala(id: string): Promise { const room: Room = { id, + nombre: stored.nombre ?? null, // El workspace ya existe en disco: NO se re-siembra ni se limpia. workspace: await createWorkspace(id), preview: null, @@ -424,6 +432,34 @@ export async function deleteRoom(id: string): Promise { return true; } +/** Lo más largo que puede ser el nombre de una sala. */ +export const MAX_NOMBRE_SALA = 60; + +/** + * Le pone nombre a una sala. Devuelve el nombre que quedó, o null si se quitó. + * + * Gana el último que escribe, como el título de un documento compartido: es un + * campo chico, se toca poco, y quien no esté de acuerdo lo vuelve a cambiar + * viendo ya lo que puso el otro. Por eso no hay bloqueo ni resolución de + * conflictos, pero sí se avisa a la sala (en index.ts): un nombre que cambia + * solo, sin saber quién fue, se siente a error. + * + * El id NO se toca. Es la URL y lo que se dicta por teléfono, así que renombrar + * nunca rompe un link ya compartido. + */ +export async function renameRoom(room: Room, crudo: unknown): Promise { + // Vacío = quitar el nombre y volver a ver el id. Es la única forma de + // deshacer, y es más natural que un botón aparte. + const texto = typeof crudo === "string" ? crudo.trim() : ""; + // Los saltos de línea romperían la cabecera de la sala, y no aportan nada a + // un nombre. + const nombre = texto ? texto.replace(/\s+/g, " ").slice(0, MAX_NOMBRE_SALA) : null; + + room.nombre = nombre; + await (await getStorage()).renameRoom(room.id, nombre); + return nombre; +} + export function addMember(room: Room, socketId: string, name: string): Member { const member: Member = { socketId, diff --git a/server/src/storage/sqlite.ts b/server/src/storage/sqlite.ts index b69d8a4..1464449 100644 --- a/server/src/storage/sqlite.ts +++ b/server/src/storage/sqlite.ts @@ -28,7 +28,10 @@ export class SqliteStorage implements Storage { id TEXT PRIMARY KEY, workspace_dir TEXT NOT NULL, created_at INTEGER NOT NULL, - last_active_at INTEGER NOT NULL + last_active_at INTEGER NOT NULL, + -- Cómo le dice la gente a esta sala. El id no cambia nunca (es la URL y + -- lo que se dicta por teléfono); esto es solo la etiqueta que se ve. + nombre TEXT ); CREATE TABLE IF NOT EXISTS messages ( @@ -62,6 +65,14 @@ export class SqliteStorage implements Storage { } catch { // ya la tiene } + + // El nombre que la gente le pone a la sala. Nullable a propósito: sin él la + // interfaz cae al id, que es lo que se ha visto siempre. + try { + this.db.exec(`ALTER TABLE rooms ADD COLUMN nombre TEXT`); + } catch { + // ya la tiene + } } async createRoom(room: StoredRoom): Promise { @@ -91,6 +102,10 @@ export class SqliteStorage implements Storage { this.db.prepare(`UPDATE rooms SET last_active_at = ? WHERE id = ?`).run(Date.now(), id); } + async renameRoom(id: string, nombre: string | null): Promise { + this.db.prepare(`UPDATE rooms SET nombre = ? WHERE id = ?`).run(nombre, id); + } + /** * Los mensajes y los historiales se van solos: sus tablas declaran * `ON DELETE CASCADE` contra `rooms(id)` y el PRAGMA de claves foráneas está @@ -201,5 +216,8 @@ function toRoom(r: Record): StoredRoom { workspaceDir: String(r.workspace_dir), createdAt: Number(r.created_at), lastActiveAt: Number(r.last_active_at), + // Las salas de antes de esta columna la traen en null, y ahí la interfaz + // cae al id. + nombre: r.nombre == null ? null : String(r.nombre), }; } diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index c6f5bc3..a50015b 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -13,6 +13,15 @@ export interface StoredRoom { workspaceDir: string; createdAt: number; lastActiveAt: number; + /** + * Cómo le dice la gente a esta sala. Null hasta que alguien la nombre, y + * entonces la interfaz cae al id. + * + * Es de la SALA, no de quien la nombró: la ven todos los que entren. El id + * sigue siendo lo que va en la URL y lo que se dicta por teléfono, así que + * renombrar no rompe ningún link ya compartido. + */ + nombre?: string | null; } export interface StoredMessage { @@ -36,6 +45,8 @@ export interface Storage { listRooms(): Promise; /** Marca actividad reciente (para saber qué salas siguen vivas). */ touchRoom(id: string): Promise; + /** Le pone nombre a la sala. `null` lo quita y se vuelve a ver el id. */ + renameRoom(id: string, nombre: string | null): Promise; /** * Borra la sala y todo lo suyo: mensajes e historiales de agentes. * diff --git a/web/src/App.tsx b/web/src/App.tsx index 3c2037f..c9e43ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,7 +20,7 @@ import { BackCanvas, type Endpoint } from "./BackCanvas.js"; import { KeyPanel, loadStoredCredencial, type Credencial } from "./KeyPanel.js"; import { useTextos } from "./i18n.js"; import { MenuSalas } from "./MenuSalas.js"; -import { recordarSala, olvidarSala } from "./historial-salas.js"; +import { recordarSala, olvidarSala, recordarNombre } from "./historial-salas.js"; import { prepararImagen, imagenesDe, @@ -241,6 +241,12 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { /** Por dónde va el arranque del preview. null = no está arrancando. */ const [arrancando, setArrancando] = useState<"contenedor" | "dependencias" | "servidor" | null>(null); const [draft, setDraft] = useState(""); + /** + * El nombre de la sala, o null si nadie la ha nombrado (ahí se ve el id). + * Es de la sala, así que llega en el `joined` y cambia para todos a la vez. + */ + const [nombre, setNombre] = useState(null); + const [editandoNombre, setEditandoNombre] = useState(false); /** Qué dice el botón de descargar ahora mismo. null = su texto normal. */ const [zipAviso, setZipAviso] = useState(null); /** @@ -339,6 +345,8 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { socket.on("joined", (p: JoinedPayload) => { setMembers(p.members); + setNombre(p.nombre ?? null); + recordarNombre(roomId, p.nombre ?? null); if (p.previewUrl) setPreviewReady(true); if (p.agents) setAgents(p.agents); if (p.orphanTurns?.length) setOrphans(p.orphanTurns); @@ -353,6 +361,13 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { if (p.previewArrancando && !p.previewUrl) setArrancando("servidor"); }); socket.on("presence", ({ members }: { members: Member[] }) => setMembers(members)); + + // Alguien de la sala le cambió el nombre: se ve al momento en la cabecera + // de todos, sin recargar. Quién fue sale en el chat. + socket.on("room:renamed", ({ nombre }: { nombre: string | null }) => { + setNombre(nombre); + recordarNombre(roomId, nombre); + }); socket.on("preview:ready", () => { setPreviewReady(true); setArrancando(null); @@ -583,6 +598,19 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { const copyLink = () => navigator.clipboard.writeText(window.location.href); + /** + * Guardar el nombre nuevo de la sala. + * + * No se pinta aquí lo que quedó: se manda al server y se espera su + * `room:renamed`, que llega igual para todos. Así el que renombra ve + * exactamente lo mismo que sus compas, recortes y espacios incluidos. + */ + const guardarNombre = (valor: string) => { + setEditandoNombre(false); + if (valor.trim() === (nombre ?? "")) return; // no cambió: nada que mandar + socketRef.current?.emit("room:rename", { nombre: valor }); + }; + /** * Bajar el proyecto de la sala como .zip. * @@ -653,7 +681,32 @@ function Sala({ roomId, name }: { roomId: string; name: string }) {
-
{roomId}
+ {editandoNombre ? ( + guardarNombre(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + // Escape descarta: se sale sin guardar lo que se llevaba escrito. + if (e.key === "Escape") { + e.currentTarget.value = nombre ?? ""; + e.currentTarget.blur(); + } + }} + /> + ) : ( +
setEditandoNombre(true)} + title={nombre ? `${roomId} — ${t.renombrarSala}` : t.renombrarSala} + > + {nombre ?? roomId} +
+ )}
{t.enLaSala(members.length)}
diff --git a/web/src/MenuSalas.tsx b/web/src/MenuSalas.tsx index ac198c9..2ec2911 100644 --- a/web/src/MenuSalas.tsx +++ b/web/src/MenuSalas.tsx @@ -130,8 +130,14 @@ export function MenuSalas({ actual }: { actual?: string }) { {otras.map((s) => (
  • setAbierto(false)}> - {s.id} - {cuando(s.visitadaEn, idioma)} + {/* El nombre manda, porque es lo que hace reconocible la + sala. El id se queda debajo: sigue siendo lo que va en + el link y lo que se dicta por teléfono. */} + {s.nombre || s.id} + + {s.nombre ? `${s.id} · ` : ""} + {cuando(s.visitadaEn, idioma)} + )}
    {t.enLaSala(members.length)}
    diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 1a3f2a7..055963e 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -40,7 +40,7 @@ const TEXTOS = { // Sala enLaSala: (n: number) => `${n} en la sala`, - renombrarSala: "doble clic para ponerle nombre", + renombrarSala: "clic para ponerle nombre", salaVacia: "La sala está vacía.", pideAlgo: "@agente crea un Next con Tailwind", hablaConLaSala: "habla con la sala — o escribe @agente para pedir algo", @@ -191,7 +191,7 @@ const TEXTOS = { // Sala enLaSala: (n: number) => `${n} in the room`, - renombrarSala: "double-click to name it", + renombrarSala: "click to name it", salaVacia: "The room is empty.", pideAlgo: "@agente build a Next app with Tailwind", hablaConLaSala: "talk to the room — or type @agente to ask for something", diff --git a/web/src/styles.css b/web/src/styles.css index c329e08..6a99e49 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -353,11 +353,28 @@ body::after { cursor: default; opacity: 0.6; } +/* Es un