Saltar a contenido

El código de la consola

Arquitectura es el backend. Esta es la otra mitad: la aplicación Next.js en frontend/, para quien está a punto de tocarla.

Stack. Next.js 15 (App Router) · React 19 · TypeScript strict · Tailwind · next-intl · TanStack Query · Zustand · vitest y Testing Library · Playwright. Gestor de paquetes y runner: bun.

Dónde vive cada cosa

Ruta
src/app/[locale]/(dashboard)/… El producto. Las rutas llevan prefijo de locale
src/app/api/… Route handlers que hacen de proxy hacia el backend
src/lib/ Clientes de API tipados, query-keys.ts y los registries de más abajo
src/hooks/ Uno por recurso — use-agents, use-permissions, …
src/stores/ Zustand, uno por asunto
src/components/<domain>/ La UI por dominio; los primitivos en ui/, los estados vacíos y de error en states/

Los Server Components son lo predeterminado. "use client" es para state, effects y handlers, no por costumbre.

Presentación de la consola

Usa PageHeader para títulos y acciones compactos. Los catálogos de tarjetas usan ListCard layout="grid" sin un panel adicional; las tablas conservan el panel. Los botones usan tokens neutros action, independientes de primary para controles seleccionados e insignias. El foco y los bordes de los campos siguen siendo visibles.

Las personas y organizaciones usan EntityAvatar: las fotos se recortan en círculos, con iniciales sobre un color estable como alternativa. Los agentes conservan sus caras generadas. Las descripciones del catálogo reservan dos líneas; al abrir un elemento se muestra la descripción completa.

El navegador nunca llama al backend

Cada petición va a /api/* de esta app, que la reenvía a FastAPI con el access token tomado de una cookie HttpOnly. Eso es lo que mantiene el token fuera del JavaScript y la URL del backend fuera del bundle del cliente.

Lo hace un único reenviador — src/lib/platform-proxy.ts — en lugar de un archivo de ruta hecho a mano por endpoint que repite las mismas doce líneas.

Una respuesta sin Cache-Control no es una respuesta que nadie cachee

El proxy estampa no-store en todo lo que el backend dejó sin marcar. Cada respuesta de aquí depende de una cookie, de un conjunto de permisos y de la cabecera de organización, y una lista que se vuelve a pedir tras una escritura tiene que llegar al servidor.

Un archivo de ruta hecho a mano debe la misma cabecera — el proxy es el único sitio que la aplica por ti.

Los datos, y dónde vive el state

Todo acceso a la API pasa por un cliente de src/lib/ y se consume a través de un hook. Nada de fetch en un componente.

Los datos del servidor viven en la capa de queries; los stores guardan solo state de UI y efímero. Registra cada query key en query-keys.ts para que la invalidación tras una escritura se mantenga consistente.

Historial del chat y actualizaciones del stream

La consola abre la página más reciente del transcript y carga mensajes anteriores bajo demanda. Los chips de proyecto consultan /conversations/{id}/metadata, con las mismas comprobaciones de lector y organización, sin cargar mensajes. Las lecturas del repositorio omiten el resumen almacenado del modelo, salvo que la reconstrucción de su historial lo solicite explícitamente.

Los sockets del chat entregan ráfagas una vez por frame de animación, con una alternativa de 50 ms para pestañas ocultas y un límite de cola de 256 frames. Los deltas contiguos de texto o razonamiento se combinan; los límites de control, herramientas y partes conservan su orden. Cambiar de conversación, cuenta u organización descarta los frames pendientes.

Se reutilizan el Markdown y los resultados de herramientas sin cambios. El desplazamiento sigue el crecimiento medido del contenido. Los dibujos terminados se montan cerca del viewport y se desmontan fuera, conservando su espacio medido; sus scripts se reinician al volver. Los dibujos en stream siguen activos y la pantalla completa suspende la copia del transcript.

Los permisos son una decisión de renderizado

use-permissions.ts da el conjunto de permisos efectivo para la organización activa. Un control que quien llama no puede usar no se renderiza — no se renderiza, en vez de renderizarse y devolver 403.

Dos trampas, y las dos ya han llegado a producción aquí:

  • Qué roles ofrece un selector es aritmética, no una lista. assignableRoles refleja la regla del servidor sobre el catálogo de permisos: un rol solo se ofrece cuando el de quien llama lo supera estrictamente. Un "todos los roles menos owner" cableado a mano es lo que le ofreció a un Admin la opción Admin y devolvió 403 una vez tecleado el correo.
  • Una página que nombra una organización en su URL es esa organización. El cliente de API estampa X-Organization-Id desde la organización activa, así que una página que actúa sobre la organización de su ruta mientras lee los permisos de la activa decide sobre los miembros de Acme según tu rol en Globex. La adopción vive en ActiveOrgGuard, una sola vez.

Cada string visible para el usuario pasa por next-intl

make lint lo impone en las dos direcciones: un string legible metido en un componente falla, y también falla una clave que el catálogo guarda y que ningún componente lee.

Tres reglas con las que la gente tropieza:

  • Un recuento es un plural de ICU, nunca un ternario. {n} file{n === 1 ? "" : "s"} es una frase que solo el inglés construye así.
  • Un sustantivo con el que concuerda la frase no es un parámetro. {matched} of {total} {noun} renderiza 3 of 40 skills en polaco. El sustantivo va dentro del plural o del select.
  • El catálogo guarda copy, y solo copy. Un falso positivo se responde con un i18n-exempt y su motivo; nunca con una clave. Responder a uno moviendo una lista de clases de Tailwind a en.json es la forma en que traducir un string una vez dejó a un componente sin sus estilos.

El inglés es la lengua de origen y se fusiona bajo cada locale, así que una traducción que falta renderiza inglés en lugar de la clave.

Los nombres propios del producto siguen en inglés en cada locale

agent, spec, capability, skill, embed, budget, run, prompt, provider, token, vault, workspace, sandbox, MCP. Nombran cosas que un cliente también encuentra en la documentación, en la API y en el YAML exportado — traducirlos en la UI y en ningún otro sitio crea dos vocabularios para un mismo producto. Flexiónalos, no los sustituyas.

Cuatro registries, y ninguna segunda fuente

Cada uno de ellos es una tabla que leen varias partes de la UI. Añadir a la tabla es el cambio entero; añadir una segunda fuente es el bug.

Guarda Se añade con
lib/tool-catalog.ts El icono, el rótulo mientras corre, el nombre al terminar y el renderizador de cada tool que registra el backend Una fila indexada por el id de tool de la capability. Un test de backend compara las dos en ambas direcciones
lib/brand-glyphs.generated.ts Cada marca de servicio, de connector y de provider, como datos de path en bruto Una fila en scripts/gen-brand-icons.ts, y después bun run gen:brand-icons. Nunca un import de un paquete de iconos
lib/dashboard/registry.ts Los widgets del dashboard y el permiso al que está sujeto cada uno Cinco ediciones, enumeradas más abajo en la página
lib/dialog-sizes.ts Un token de anchura y un token de forma por diálogo Eligiendo un token, nunca una altura a medida

Dos cosas que debe una superficie nueva

Las dos son registries con el mismo modo de fallo: una página añadida en cualquier otro sitio simplemente no está, nada falla, y la funcionalidad se publica invisible.

Una parada del recorrido. lib/onboarding/tour.ts es el recorrido pasivo que reproduce el "?" de una página, y flows.ts es la creación guiada que ofrece al final. Una página sin parada no renderiza ningún "?" — así que una página nueva cuya cabecera no tiene botón de ayuda no se ha registrado. Sujeta el paso al permiso que lleva su control, márcalo optional cuando el control necesita que existan datos, y áncralo en algo acotado.

Un widget del dashboard, si la funcionalidad produce un estado que alguien querría ver de un vistazo. Cinco ediciones: el id y la definición en dashboard/registry.ts, el componente en components/dashboard/widgets/, una colocación en layouts.ts, el id replicado en backend/app/schemas/dashboard_layout.py — un test falla cuando esos dos se desvían — y el copy en en.json y en cada traducción que hay a su lado.

Verificar

Desde frontend/. En la raíz del repositorio vitest no encuentra configuración, informa de unos 164 fallos fantasma y deja un directorio de caché perdido.

bunx vitest run src/components/chat/usage-meter.test.tsx   # while writing

Una vez, antes de hacer push — desde la raíz del repositorio:

make lint-frontend && make test-frontend-cov && make build-frontend

test:coverage, no test:run

El job que ejecuta CI mide la cobertura y falla por debajo del 100 % de líneas, sentencias y funciones, o del 97,5 % de ramas. Una suite en la que pasan todos los tests puede estar roja igualmente, y lo ha estado.

Borrar una rama muerta es más fácil que cubrirla: un ?? "" detrás de una comprobación que ya demostró el valor es de los que el gate hace bien en señalar.

Un spec que agota su tiempo suele ser la máquina. testTimeout es 15 s y asyncUtilTimeout 5 s, los dos medidos y no adivinados. Ninguno de los dos es razón para conservar un spec que monta más de lo que leen sus assertions.

Recapitulación

  • El navegador habla con /api/* de esta app, nunca con FastAPI — un único reenviador, y estampa no-store.
  • Los datos del servidor viven en la capa de queries; los stores guardan solo state de UI.
  • Un control que quien llama no puede usar no se renderiza, y los selectores de rol son calculados, no listados.
  • El copy pasa por next-intl, los recuentos son plurales de ICU, y el catálogo guarda copy y solo copy.
  • Una superficie nueva debe una parada del recorrido, y un widget si tiene estado legible de un vistazo — las dos son registries silenciosos.

El backend → · Añadir una funcionalidad → · Pruebas →