From 1ad4e74e21cd9974a3ce4a6476688a0420bf9f26 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Sun, 9 Aug 2026 23:08:50 -0600 Subject: [PATCH 1/2] Una sala nueva ya no muestra el chat de la anterior Al componente de la Sala le faltaba `key`, asi que al cambiar de sala React veia el mismo componente en el mismo sitio, reusaba la instancia y conservaba su estado: los mensajes, los agentes y el preview de la sala ANTERIOR. En una sala con historial no se notaba, porque el `joined` llegaba con mensajes y pisaba lo viejo. En una recien creada si: llega vacio, nada sobrescribe, y acababas en tu sala nueva leyendo la conversacion de otra. Con `key={roomId}` React remonta al cambiar de sala. El socket viejo se cierra solo, porque el efecto ya hacia `disconnect` al desmontar. Co-Authored-By: Claude Opus 5 --- web/src/App.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 4b5049a..02d716f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -96,8 +96,18 @@ export function App() { ); } - // Pantalla 3: la sala. - return ; + /** + * Pantalla 3: la sala. + * + * La `key` es lo que hace que al cambiar de sala se empiece de cero. Sin + * ella React ve el mismo componente en el mismo sitio, reusa la instancia y + * conserva su estado: los mensajes, los agentes y el preview de la sala + * ANTERIOR. Al entrar a una sala con historial no se notaba, porque el + * `joined` llegaba con mensajes y pisaba lo viejo; al crear una sala nueva sí, + * porque llega vacío y nada sobrescribe. Aparecías en una sala recién creada + * leyendo la conversación de otra. + */ + return ; } // ── Pantalla: crear / entrar a sala ──────────────────────────────────────── From e6753bd73adeeb0916d606ee8bb6e56aa6cf9ffc Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Sun, 9 Aug 2026 23:55:37 -0600 Subject: [PATCH 2/2] Borrar una sala de verdad, no solo de tu lista La X del menu quitaba la sala del localStorage y ya: el proyecto, el chat y el contenedor seguian ahi para siempre. Si la quitas de la lista es porque ya no la vas a usar, asi que ahora se borra completa. Se va todo: el workspace en disco con sus hermanos (.adjuntos, .turns.json, .bookmarks.json), el contenedor, y las tres tablas de la BD. El orden importa: primero se apaga lo que corre, porque borrar el directorio con el dev server vivo y el contenedor montado deja procesos escribiendo en archivos que ya no existen. Se pregunta antes, con el nombre de la sala en la pregunta: es irreversible y NO es personal. La sala es de quien tenga el link, asi que borrarla se lleva el trabajo de los compas que esten ahi. A quien este dentro se le avisa por socket (`room:deleted`), porque si no se quedaba con un preview que ya no responde sin saber por que. Si el server falla, la sala NO se quita de la lista: seguiria existiendo, y esconderla del menu solo taparia algo que sigue ocupando disco. Probado en local: se borro una sala con adjuntos y no quedo rastro ni en disco, ni en contenedores, ni en la BD (el server responde "sala no encontrada"). Co-Authored-By: Claude Opus 5 --- server/src/index.ts | 22 +++++++++++++ server/src/rooms.ts | 38 +++++++++++++++++++++- server/src/storage/sqlite.ts | 13 ++++++++ server/src/storage/types.ts | 7 ++++ web/src/App.tsx | 11 ++++++- web/src/MenuSalas.tsx | 62 +++++++++++++++++++++++++++++++----- web/src/i18n.tsx | 14 ++++++++ web/src/socket.ts | 11 +++++++ web/src/styles.css | 16 ++++++++++ 9 files changed, 184 insertions(+), 10 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 420df9e..42be570 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -18,6 +18,7 @@ import { maybeStartPreview, ensureRunner, loadRoomIndex, + deleteRoom, type Room, type SelectedElement, } from "./rooms.js"; @@ -311,6 +312,27 @@ fastify.get<{ Params: { id: string } }>("/rooms/:id/export/estado", async (req, }; }); +/** + * Borrar una sala: su proyecto, su contenedor y su historial. + * + * Es DELETE y no un POST a algo: destruye un recurso entero, y el método dice + * eso solo. Va bajo `/rooms` por lo mismo que el export (el proxy del preview + * no toca ese prefijo). + * + * No hay dueño que comprobar: la sala es de quien tenga el link, y sin cuentas + * no existe forma de saber quién la creó. Lo que protege de un borrado por + * accidente es la confirmación de la interfaz, no este endpoint. + */ +fastify.delete<{ Params: { id: string } }>("/rooms/:id", async (req, reply) => { + const borrada = await deleteRoom(req.params.id); + if (!borrada) return reply.code(404).send({ error: "sala no encontrada" }); + + // A quien esté dentro se le avisa: su sala acaba de dejar de existir y el + // preview que tiene enfrente ya no responde. + io.to(req.params.id).emit("room:deleted", {}); + return { ok: true }; +}); + // Info de una sala (para saber la URL del preview al entrar). fastify.get<{ Params: { id: string } }>("/rooms/:id", async (req, reply) => { const room = getRoom(req.params.id); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index cd2806f..8e271c4 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { createWorkspace, type Workspace } from "./engine/workspace.js"; import { startPreview, detectLaunch, type Preview } from "./engine/preview.js"; @@ -347,6 +347,42 @@ export async function stopAllPreviews(): Promise { ); } +/** + * Borra una sala del todo: su proyecto, su contenedor y su rastro en la BD. + * + * Es irreversible y es PARA TODOS: la sala es de quien tenga el link, no de + * quien aprieta el botón. Por eso la confirmación vive en la interfaz, y por eso + * la Sala ofrece bajarse el .zip antes. + * + * El orden importa. Primero se apaga lo que está corriendo (el dev server tiene + * el workspace abierto y el contenedor lo tiene montado); borrar el directorio + * con ellos vivos deja procesos escribiendo en archivos que ya no existen y, en + * el caso del contenedor, un montaje colgado. Al final se saca de memoria, para + * que nadie la resucite a media limpieza. + */ +export async function deleteRoom(id: string): Promise { + const room = rooms.get(id) ?? (await wakeRoom(id)); + if (!room) return false; + + // Fuera de memoria antes de tocar nada: si alguien pide entrar mientras se + // borra, se encuentra una sala que ya no existe en vez de una a medio morir. + rooms.delete(id); + + await room.preview?.stop().catch(() => {}); + if (room.container) await stopContainer(id).catch(() => {}); + + // El workspace y lo que cuelga de él: los adjuntos y los turnos viven al lado, + // como hermanos con el id de la sala por prefijo (workspaces/.adjuntos). + const dir = room.workspace.dir; + await rm(dir, { recursive: true, force: true }).catch(() => {}); + await rm(`${dir}.adjuntos`, { recursive: true, force: true }).catch(() => {}); + await rm(`${dir}.turns.json`, { force: true }).catch(() => {}); + await rm(`${dir}.bookmarks.json`, { force: true }).catch(() => {}); + + await (await getStorage()).deleteRoom(id); + return true; +} + 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 9c58414..b69d8a4 100644 --- a/server/src/storage/sqlite.ts +++ b/server/src/storage/sqlite.ts @@ -91,6 +91,19 @@ export class SqliteStorage implements Storage { this.db.prepare(`UPDATE rooms SET last_active_at = ? WHERE id = ?`).run(Date.now(), 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á + * activo desde el constructor. Aun así se borran a mano primero, porque si + * ese PRAGMA se cayera algún día esto dejaría filas huérfanas en silencio y + * la BD crecería sin que nadie lo notara. + */ + async deleteRoom(id: string): Promise { + this.db.prepare(`DELETE FROM agent_histories WHERE room_id = ?`).run(id); + this.db.prepare(`DELETE FROM messages WHERE room_id = ?`).run(id); + this.db.prepare(`DELETE FROM rooms WHERE id = ?`).run(id); + } + async appendMessage(m: StoredMessage): Promise { this.db .prepare( diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index 921b94a..c6f5bc3 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -36,6 +36,13 @@ export interface Storage { listRooms(): Promise; /** Marca actividad reciente (para saber qué salas siguen vivas). */ touchRoom(id: string): Promise; + /** + * Borra la sala y todo lo suyo: mensajes e historiales de agentes. + * + * Solo la parte de base de datos. El workspace en disco y el contenedor los + * quita quien llama, porque no viven aquí. + */ + deleteRoom(id: string): Promise; appendMessage(msg: StoredMessage): Promise; /** Los mensajes de una sala, en orden. */ diff --git a/web/src/App.tsx b/web/src/App.tsx index 02d716f..3c2037f 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 } from "./historial-salas.js"; +import { recordarSala, olvidarSala } from "./historial-salas.js"; import { prepararImagen, imagenesDe, @@ -429,6 +429,15 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { socket.on("error:join", ({ message }: { message: string }) => alert(message)); + // Alguien borró esta sala mientras estabas dentro. Sin esto te quedabas + // frente a un preview que ya no responde y un chat que no manda nada, + // sin saber por qué. + socket.on("room:deleted", () => { + olvidarSala(roomId); + alert(t.salaBorrada); + window.location.hash = "#/"; + }); + // Solo a mí: mi key faltaba o el server la rechazó. Abre el panel. socket.on("error:key", ({ message }: { message: string }) => { setKeyError(message); diff --git a/web/src/MenuSalas.tsx b/web/src/MenuSalas.tsx index 0ca0393..ac198c9 100644 --- a/web/src/MenuSalas.tsx +++ b/web/src/MenuSalas.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { salasVisitadas, olvidarSala, type SalaVisitada } from "./historial-salas.js"; -import { createRoom } from "./socket.js"; +import { createRoom, borrarSala } from "./socket.js"; import { useTextos } from "./i18n.js"; /** @@ -17,8 +17,51 @@ export function MenuSalas({ actual }: { actual?: string }) { const [abierto, setAbierto] = useState(false); const [salas, setSalas] = useState([]); const [creando, setCreando] = useState(false); + /** El id de la sala que se está borrando ahora mismo, o null. */ + const [borrando, setBorrando] = useState(null); + /** La que se acaba de borrar, para avisarlo. Se limpia sola. */ + const [borrada, setBorrada] = useState(null); const cajaRef = useRef(null); + useEffect(() => { + if (!borrada) return; + const id = setTimeout(() => setBorrada(null), 4000); + return () => clearTimeout(id); + }, [borrada]); + + /** + * Borrar una sala de verdad, no solo quitarla de esta lista. + * + * Se pregunta antes porque es irreversible y NO es personal: la sala es de + * quien tenga el link, así que borrarla se lleva el trabajo de los compas que + * estén ahí. El nombre va en la pregunta para que se vea cuál se va a ir: en + * una lista de salas que se llaman parecido, "¿seguro?" a secas no dice nada. + * + * Si el server falla, la sala NO se quita de la lista: seguiría existiendo y + * borrarla del menú solo escondería algo que sigue ocupando disco. + */ + const borrar = async (id: string) => { + if (!confirm(t.confirmarBorrarSala(id))) return; + setBorrando(id); + try { + await borrarSala(id); + olvidarSala(id); + setSalas(salasVisitadas()); + if (id === actual) { + // Borraste la sala donde estabas: ahí ya no hay nada que ver, y como el + // menú se va contigo, el aviso de abajo no se alcanzaría a leer. + alert(t.salaBorradaOk(id)); + window.location.hash = "#/"; + } else { + setBorrada(id); + } + } catch (e) { + alert(t.noSePudoBorrar + String(e)); + } finally { + setBorrando(null); + } + }; + const crearOtra = async () => { setCreando(true); try { @@ -75,6 +118,11 @@ export function MenuSalas({ actual }: { actual?: string }) {
{t.tusSalas}
+ {/* Que la fila desaparezca no dice si se borró la sala o solo se quitó + del menú. Este aviso lo confirma, y se va solo: es una noticia de + paso, no un estado en el que la interfaz se quede. */} + {borrada &&

{t.salaBorradaOk(borrada)}

} + {otras.length === 0 ? (

{t.sinOtrasSalas}

) : ( @@ -87,14 +135,12 @@ export function MenuSalas({ actual }: { actual?: string }) { ))} diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 8afde6e..e5a1e80 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -104,6 +104,14 @@ const TEXTOS = { sinOtrasSalas: "Todavía no has entrado a otra sala.", crearOtraSala: "Crear otra sala", quitarDeLaLista: "Quitar de la lista", + borrarSala: "Borrar la sala", + // Se nombra la sala y se dice que es para todos: borrarla se lleva el + // trabajo de quien estuviera ahí, no solo tu acceso. + confirmarBorrarSala: (id: string) => + `¿Borrar la sala ${id}? Se va el proyecto y el chat, para ti y para todos los que entren con el link. No se puede deshacer.`, + noSePudoBorrar: "no se pudo borrar la sala: ", + salaBorrada: "Alguien borró esta sala. El proyecto y el chat ya no existen.", + salaBorradaOk: (id: string) => `Se borró ${id}.`, // Historial historial: "Historial", @@ -246,6 +254,12 @@ const TEXTOS = { sinOtrasSalas: "You haven't joined another room yet.", crearOtraSala: "Create another room", quitarDeLaLista: "Remove from the list", + borrarSala: "Delete the room", + confirmarBorrarSala: (id: string) => + `Delete room ${id}? The project and the chat are gone, for you and for anyone with the link. This can't be undone.`, + noSePudoBorrar: "couldn't delete the room: ", + salaBorrada: "Someone deleted this room. The project and the chat are gone.", + salaBorradaOk: (id: string) => `${id} deleted.`, // Historial historial: "History", diff --git a/web/src/socket.ts b/web/src/socket.ts index 89d92fa..b1a85a5 100644 --- a/web/src/socket.ts +++ b/web/src/socket.ts @@ -110,6 +110,17 @@ export async function createRoom(): Promise { return data.id; } +/** + * Borra la sala del servidor: proyecto, contenedor e historial. + * + * Irreversible, y para todos los que estén en ella. Quien llame a esto tiene + * que haber preguntado antes. + */ +export async function borrarSala(id: string): Promise { + const res = await fetch(`${SERVER_URL}/rooms/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("no se pudo borrar la sala"); +} + // ── Socket ────────────────────────────────────────────────────────────────── export function connectSocket(): Socket { diff --git a/web/src/styles.css b/web/src/styles.css index 89ff593..5193c7f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -258,6 +258,22 @@ body::after { padding: 4px 8px 10px; line-height: 1.5; } +/* Confirma que la sala se borró de verdad, no solo de esta lista. Aparece + suave para que se note que es nuevo, y se quita solo a los segundos. */ +.menu-borrada { + font-size: 11.5px; + color: var(--dim); + padding: 2px 8px 8px; + animation: aparecer 0.2s ease-out; +} +@keyframes aparecer { + from { + opacity: 0; + } + to { + opacity: 1; + } +} .menu-lista { list-style: none; display: flex;