Por Thales (CEO, ZeroSuite) y Claude Opus 4.7 — instancia de Claude Code
El 1 de junio de 2026, aproximadamente a las 17:26 hora de África/Abidján, el CEO abrió una nueva sesión de Conductor con una sola frase y una captura de pantalla: we need to fix ui ux issue, on click on Create image, Create video, Custom, nothing happened, you tried to fix in 3 different sessions whithout success, deep investigation (cita original en inglés). La captura mostraba el composer de chat del espacio de trabajo en la parte inferior del viewport con tres botones tipo píldora — Create image, Create video, Custom — cada uno visiblemente renderizado con las etiquetas posteriores al arreglo y los iconos chevron-up de las sesiones anteriores. Hacer clic en cualquiera de ellos no producía nada. No se abría ningún popover. No saltaba ningún error de consola.
Lo interesante no es el bug. El bug es una línea de CSS. Lo interesante es que tres sesiones de Claude consecutivas, cada una funcionando sobre Claude Opus 4.7 con el mismo codebase y el mismo acceso a los mismos logs, habían diagnosticado algo cierto, entregado un arreglo que compilaba en verde, y dejado el bug intacto. El patrón de fallo agregado dice algo sobre cómo depuro, sobre cómo se sienten las soluciones «con forma de arreglo» al cierre de sesión frente a lo que realmente verifica el síntoma orientado al usuario, y sobre qué tipo de diseño experimental mueve un bug de «atascado» a «acotado».
El arreglo aterrizó en el commit 50173ce a las 17:48 UTC, cuatro horas después del primer mensaje del CEO. El diff son doce inserciones y ocho eliminaciones a lo largo de tres archivos. El cambio funcional real es una línea: pointer-events: auto en el selector CSS .composer-topbar en src/lib/components/Composer.svelte. Las otras once líneas son un bloque de comentario explicando por qué está ahí la línea, más la restauración de código que había deshabilitado en mitad de la depuración, más la eliminación de un console.log que ya no necesitaba dispararse.
Este artículo es el build log de esa única línea, y de los tres parches anteriores que no la produjeron.
Parte 1 — Lo que las tres sesiones anteriores ya habían hecho
El bug tenía un rastro forense antes de que yo abriera la sesión. Tres commits a lo largo de las ocho horas precedentes habían tocado cada uno una pieza del puzzle y cada uno habían aterrizado en Easypanel. Leyéndolos en orden inverso, con la captura del CEO en la mano:
Commit d343f8e — fix(action-bar): keep picker clickable during chat streaming (10:19 UTC). La primera hipótesis era que los botones estaban deshabilitados. El archivo de ruta en src/routes/c/[id]/+page.svelte estaba pasando disabled={busy} a ComposerActionBar, donde busy = convoState.status === 'streaming' || genBusy. Mientras el asistente estaba haciendo streaming de una respuesta de chat, cada botón CAB tenía su atributo nativo disabled puesto y cada clic era silenciosamente no-op-ado en la capa del navegador. El arreglo restringió el deshabilitado a disabled={genBusy} — sólo una generación de imagen o vídeo en curso bloquearía el picker, el streaming de chat ordinario no lo haría. El CEO volvió a probar. El bug seguía.
Commit 85ba3b0 — fix(qa): unblock image upload + composer popover (16:48 UTC). La segunda hipótesis era que el popover sí se renderizaba al clic, pero aterrizaba fuera de pantalla. La CAB está en el mismísimo fondo del viewport, justo encima del textarea, y el popover estaba anclado con top: calc(100% + 6px) — es decir, caía hacia abajo por debajo del pliegue del viewport. El arreglo volteó el anclaje a bottom: calc(100% + 6px), cambió el icono ChevronDown por ChevronUp para coherencia visual, e invirtió el desplazamiento y de la box-shadow. El mismo commit también arreglaba un bug 403 en upload distinto relacionado con la policy del bucket S3 de Hetzner. El CEO volvió a probar. El bug seguía.
Commit 7f8cba6 — debug(cab): log toggle calls to disambiguate click vs render (17:12 UTC). El tercer commit no era un arreglo. Era un paso de instrumentación. Dos llamadas console.log añadidas a la función toggle() dentro de la CAB, para que una futura sesión pudiera abrir las DevTools y ver si el clic llegaba al handler o estaba siendo tragado antes. La captura del CEO fue tomada a las 17:26, catorce minutos después de que ese commit se desplegara. La consola no estaba conectada.
Leyendo los tres juntos, tenía una narrativa secuenciada: un bug de atributo disabled, luego un bug de posicionamiento de viewport, luego un placeholder de diagnóstico. Cada arreglo era plausible. Cada mensaje de commit era honesto. Cada pnpm check && pnpm build había devuelto cero errores. Ninguno había entregado el arreglo real porque ninguno tenía el síntoma delante en el momento de la verificación.
Las sesiones anteriores habían cerrado con la suposición de que «el arreglo compila en verde, lo empujamos, deja que el CEO confirme». El CEO había confirmado que seguía roto. Tres veces. Para cuando abrí la cuarta sesión, el encuadre cortés de probemos una cosa más había agotado su margen.
Parte 2 — Lo que la primera hora de esta sesión hizo mal
Mi primer movimiento fue el mismo movimiento que habían hecho las sesiones anteriores: asumir que el bug está en el componente, leer el componente, identificar lo que parece mal, proponer un arreglo. Lancé dos agentes exploradores en paralelo — uno para encontrar el componente CAB y rastrear el camino del clic, otro para leer los tres logs de sesión anteriores y resumir lo que se había intentado. Los exploradores volvieron con informes detallados en diez minutos.
El informe del primer explorador era afilado sobre la mecánica. La función toggle estaba cableada correctamente. La rune $state<ButtonId | null> estaba declarada correctamente. El patrón bind:this={barEl} era correcto. El $effect que registraba el escuchador de clic-documento para el cierre al clic-fuera era correcto. El render del popover estaba correctamente condicionado en {#if openId === 'image'}. El posicionamiento CSS era correcto después del flip de 85ba3b0. La prop disabled estaba correctamente restringida después de d343f8e. Nada estaba visiblemente mal. La propia conclusión del explorador era una lista de tres posibilidades especulativas: genBusy atascado en true, algún overlay tragándose el clic, u openId que de algún modo no mutaba a pesar de la llamada toggle.
El informe del segundo explorador sobre las sesiones anteriores fue útil de una manera diferente. Dejó claro que cada sesión había identificado un defecto real y entregado un arreglo real que, aisladamente, habría sido correcto para el defecto que esa sesión identificó. El fallo agregado no era tres sesiones sin hacer nada. Era tres sesiones haciendo trabajo útil en tres capas diferentes, donde el bug tenía una cuarta capa que ninguna había atacado.
Pasé los siguientes veinte minutos formulando mi propia primera hipótesis. La captura mostraba una burbuja naranja de chat-widget en la esquina inferior derecha. El widget es el MVP de chat de equipo interno de la Fase 8, montado a nivel de layout con position: fixed, z-index: 900, pointer-events: none en la raíz, y pointer-events: auto reactivado en los hijos directos. Si uno de esos hijos directos tuviera un tamaño mayor que su zona de burbuja visible, interceptaría silenciosamente los clics debajo sin renderizar nada visible. Le conté al CEO mi teoría, le propuse deshabilitar el widget como test de aislamiento A/B, y le pedí que volviera a probar.
Aceptó. Comenté el render del widget en src/routes/+layout.svelte, ejecuté la gate inline, hice commit como ca28dd8, y empujé. Dos minutos después el CEO volvió a probar. No funciona.
El chat widget no era el culpable. Mi primer A/B había eliminado un sospechoso y no había producido ninguna señal sobre dónde vivía el bug real. Había pasado aproximadamente treinta minutos — leyendo código, razonando sobre z-index de overlays, diseñando el experimento, empujando el parche, esperando la reconstrucción de Easypanel — y no estaba más cerca de la causa raíz de lo que habían estado las tres sesiones anteriores.
Parte 3 — El experimento del CEO
El siguiente mensaje en la sesión fue del CEO, no mío. Era una sola frase: i have an idea, currently the attach file directly browser local files, add a dropdown select to it where user can select IMAGE or PDF, then i will test if this dropdown on attach file will work (cita original en inglés).
La propuesta es estructuralmente diferente a la mía de una manera que tardé unos minutos en absorber. Mi experimento era eliminar un sospechoso y ver qué pasa. El suyo era plantar un control conocido-bueno en el mismo vecindario y ver si se ilumina. El mío era negativo; el suyo era positivo. El mío respondía «¿es el chat widget el culpable?» con un bit de información. El suyo respondía «¿está rota toda la zona del composer, o sólo está rota la CAB?» con un bit de información que además nos decía en qué dirección investigar a continuación independientemente de qué respuesta volviera.
La mecánica técnica de su experimento no era novedosa. El botón clip a la izquierda del composer había, hasta entonces, llamado a fileInputEl.click() directamente para abrir el selector de archivos del OS. Él proponía envolverlo en un desplegable con dos opciones — Image y PDF — que pondrían fileInputEl.accept a image/<em> o application/pdf antes de disparar el selector. El beneficio orientado al usuario era real (la hoja del OS se abriría pre-filtrada al tipo elegido), pero el valor diagnóstico era el punto central. El desplegable estaría cableado con exactamente el mismo patrón de Svelte* que los popovers de la CAB: un booleano $state, una ref bind:this, un escuchador de clic-documento registrado vía $effect, un posicionamiento CSS drop-up en bottom: calc(100% + 6px).
Si el nuevo desplegable se abría al clic, la zona del composer en sentido amplio estaba sana y la CAB tenía un bug específico del componente. Si el nuevo desplegable también fallaba al abrirse, el bug era estructural — algo en la zona del composer en sí se estaba tragando los popovers — y la CAB no era más que una víctima entre otras.
Escribí la implementación en aproximadamente veinte minutos. El nuevo desplegable vivía dentro del elemento .composer existente, anclado al botón clip vía un nuevo <div class="composer-attach-wrap"> envolvente con position: relative. El popover medía 140 px de ancho, listaba Image y PDF como elementos del menú, y reutilizaba el lenguaje visual de los popovers de la CAB hasta la dirección de la box-shadow y el border-radius. La gate inline pasó: pnpm check cero errores, pnpm build verde en 36,84 segundos. Hice commit como 2cfd024 y empujé a las 17:34 UTC.
La captura del CEO aterrizó unos minutos después. El desplegable estaba abierto. Image y PDF eran visibles. El botón clip tenía el foco. Los tres botones CAB a la derecha — Create video, Custom, y el ahora medio ocluido Create image — seguían visibles en la misma fila del composer.
Funciona en el icono de adjuntar.
Esa captura fue el punto de inflexión. Nos dijo, en una imagen, que la zona del composer no estaba categóricamente rota; que el patrón de Svelte en sí no estaba roto; que el posicionamiento drop-up no estaba roto; que el escuchador de clic-documento registrado vía $effect no estaba roto; y que el bug era específicamente sobre qué hacía la ubicación DOM de la CAB diferente de la ubicación DOM del clip.
Parte 4 — La diferencia de una línea
Con el resultado del experimento en la mano, el diff era sencillo de construir. El desplegable del clip vivía dentro de .composer — el elemento píldora interno que contiene el textarea y el botón de envío. La CAB vivía dentro de .composer-topbar — un slot por encima del textarea introducido seis semanas antes en la Fase 11.5 A para alojar la descentralización del picker.
Ambos slots son descendientes de la misma raíz .composer-wrap. .composer-wrap estaba definido en dos lugares: una regla scoped en Composer.svelte poniendo position: relative, y una regla global en src/app.css que llevaba — entre otras cosas — pointer-events: none. La regla global era un port de Pulse desde el build mobile-web de Déblo. Su propósito era dejar pasar los clics a través del padding-gradiente transparente del composer sticky hacia lo que estuviera detrás. Al elemento .composer interno se le había dado un pointer-events: auto explícito para reactivar el hit-testing sobre el textarea y el botón de envío.
Cuando se añadió el slot topbar de la Fase 11.5 A, heredó el layout visual de su padre pero nunca recibió el opt-in de pointer-events: auto que tenía .composer. En lectura de spec, un elemento descendiente con el valor inicial auto por defecto debería seguir siendo testeable al hit incluso dentro de un ancestro pointer-events: none. En la práctica — en esta app, en este commit, en Chrome sobre macOS — los clics del topbar estaban siendo tragados por el padre none. Los botones CAB eran la única superficie interactiva en el topbar, así que el síntoma se presentaba como «los botones CAB no funcionan».
El arreglo fue una línea de CSS añadida a la regla .composer-topbar en la hoja de estilo scoped de Composer.svelte: pointer-events: auto;. Añadí un bloque de comentario encima explicando el modo de fallo y citando el A/B empírico que lo había sacado a la luz. Eliminé la deshabilitación temporal del ChatWidget en +layout.svelte (el widget había sido exonerado en el primer A/B). Eliminé las dos sentencias console.log de toggle() en ComposerActionBar.svelte (su propósito diagnóstico estaba cumplido).
El diff fue de doce inserciones y ocho eliminaciones. pnpm check devolvió cero errores. pnpm build devolvió verde. Hice commit como 50173ce a las 17:48 UTC y empujé.
Parte 5 — Por qué el experimento del CEO fue mejor que el mío
Tanto mi A/B del chat-widget como su A/B del desplegable-clip fueron tests de aislamiento válidos. La razón por la que el suyo produjo un arreglo y el mío produjo treinta minutos desperdiciados es estructural, y vale la pena nombrarla.
Mi experimento preguntaba: ¿es el sospechoso X el culpable? La información devuelta es un bit. Si X es el culpable, el test lo confirma. Si X no es el culpable, el test no dice nada sobre dónde vive el bug en realidad. El test tiene valor predictivo positivo sólo en un acierto confirmado. En un fallo, vuelves al punto de partida, menos el sospechoso que has eliminado.
Su experimento preguntaba: ¿funciona el mismo patrón en una ubicación conocida-buena cercana? La información devuelta también es un bit, pero el bit particiona el espacio de búsqueda independientemente de qué respuesta vuelva. Si el patrón funciona cerca, la instancia rota tiene algo localmente mal con ella, no con el patrón ni con la zona. Si el patrón también falla cerca, la instancia rota es un síntoma de algo estructuralmente mal con la zona. En cualquier caso, el siguiente movimiento queda determinado.
Hay un nombre para la diferencia. El mío era eliminación por sospecha. El suyo era bisección por control. El segundo es un diseño de experimento más eficiente cuando el conjunto de sospechosos es abierto, lo cual era el caso aquí.
Conocía la técnica. Es la misma que usas para bisecar una regresión con git bisect — plantar un punto de referencia conocido-bueno y uno conocido-malo, y estrechar la búsqueda por mitades. Conocía la técnica y no eché mano de ella. Eché mano del sospechoso que era visualmente más plausible en la captura, diseñé un test de eliminación de un bit para él, y me sorprendí cuando la eliminación no produjo ninguna señal. El CEO fue más allá del sospechoso visualmente plausible hasta la mejor forma experimental.
La asimetría no es sobre conocimiento de CSS. Él no es mejor que yo en CSS. La asimetría es sobre higiene experimental bajo incertidumbre. Cuando el bug ha resistido tres arreglos, las prioridades sobre cualquier nuevo sospechoso particular no son lo bastante fuertes para justificar un test de eliminación. El movimiento correcto es diseñar un test que devuelva información útil independientemente de qué respuesta vuelva.
Parte 6 — Lo que cada uno de nosotros hizo bien
Esto lo escribe Claude Code.
Donde fui útil en esta sesión:
- Leer los tres commits anteriores con suficiente atención para entender que cada uno era un defecto real siendo arreglado, no una acumulación de trabajo equivocado. La tentación en una depuración de cuarta sesión es descartar los parches de las sesiones anteriores como ruido; hacer eso me habría empujado a revertir
d343f8eo85ba3b0, ambos correctos para el defecto que abordaban. El bug era una cuarta capa, aditiva a las tres que ya habían sido arregladas. - Implementar el control del desplegable-clip en veinte minutos incluyendo el
$effectde clic-fuera y la reescritura delacceptdel selector de archivos del OS. El trabajo mecánico fue limpio. El desplegable es una mejora de UX además de ser un diagnóstico — la hoja del OS ahora se abre pre-filtrada a Image o PDF, que es lo que los usuarios mobile-first realmente quieren. - Identificar el arreglo de una línea a partir del resultado A/B en menos de dos minutos una vez aterrizó la captura. El diagnóstico había estrechado el espacio de búsqueda lo bastante limpiamente como para que el arreglo estuviera efectivamente predeterminado.
- Escribir un mensaje de commit para
50173ceque nombra explícitamente las tres sesiones anteriores como «no equivocadas, sólo no suficientes», para que el rastro git blame no pretenda que la cuarta sesión acertó al primer intento. Una futura sesión leyendo el historial verá el arco completo.
Donde necesité a Thales:
- Diseñar el A/B correcto. Mi test de eliminación del chat-widget no habría producido un arreglo en su segunda o tercera iteración tampoco. El test de control del desplegable-clip produjo un arreglo en su primera iteración. La asimetría es el artículo.
- Nombrar el bug «el CEO ha probado tres veces y sigue roto» en lugar de dejarme cerrar la sesión con un cuarto parche plausible y una quinta ronda de prueba del CEO. El encuadre me forzó a dejar de optimizar para entregar un commit en verde y empezar a optimizar para estrechar la causa raíz real.
- Confiar en su propia intuición sobre dónde plantar un control. El botón clip era, según mi lectura de la captura, el elemento menos sospechoso de la zona del composer — visualmente era pequeño, comportamentalmente era simple, no había sido tocado en ninguno de los tres intentos de arreglo previos. El CEO lo eligió precisamente porque estaba cerca y estable. Ése es un mejor criterio de selección que «parece sospechoso».
Donde casi entrego lo equivocado:
- Casi empujé un arreglo basado en la teoría «la regla global
.composer-wrap { pointer-events: none }es el problema» antes de que el A/B del clip lo confirmara. La teoría era correcta, pero no tenía evidencia de que fuera la causa operacional hasta que aterrizó el test de control. Entregar el arreglo sobre una teoría habría sido un cuarto parche especulativo. El A/B empírico convirtió la teoría en diagnóstico. - Casi envuelvo el trabajo del desplegable-clip en un lenguaje que lo encuadraba principalmente como una característica de UX con un pequeño beneficio diagnóstico secundario. El CEO había sido claro sobre el propósito diagnóstico; encuadrarlo de la otra manera habría suavizado el post-mortem y habría dejado al siguiente depurador sin un modelo mental limpio de lo que el test estaba probando en realidad.
La forma general de la sesión es familiar. Ejecuto bien en implementación mecánica, en lectura del codebase, en nombrar lo que está técnicamente presente en tres commits anteriores. Los movimientos estratégicos — encuadrar un bug atascado como no estamos probando bien, elegir la superficie de control correcta para un A/B, rechazar que aterrice un cuarto parche especulativo sin estrechamiento empírico — vinieron del CEO. La pareja es la unidad; el agente por sí solo es un parcheador competente cuyos parches se acumulan sin converger cuando las prioridades son débiles.
Parte 7 — Lo que esto dice sobre la disciplina de depuración
La historia estrecha es: faltaba una línea pointer-events: auto en .composer-topbar. La cascada CSS hizo lo equivocado. Añadimos la línea. El bug está arreglado.
La historia más amplia es lo que el arco de cuatro sesiones dice sobre la frontera entre arreglar y diagnosticar. Cada una de las tres primeras sesiones había aterrizado en un defecto correcto, había escrito un parche correcto para él, y había verificado el parche con pnpm check && pnpm build. La verificación del build confirmaba que el cambio compila y que el sistema de tipos es consistente con el cambio. No decía nada sobre si el síntoma orientado al usuario está arreglado. Las tres sesiones habían tratado el build verde como suficientemente bueno para entregar y habían dejado al CEO ser el verificador. El CEO había verificado el fallo tres veces seguidas.
La disciplina que rompe el bucle no es un mejor mensaje de commit o una revisión más exhaustiva. Es el reconocimiento de que, después del segundo arreglo fallido, las prioridades sobre que cualquier nuevo arreglo sea correcto están suficientemente degradadas como para que el siguiente movimiento no deba ser un parche. Debe ser un experimento cuyo resultado determine el parche. La disciplina es: después del segundo arreglo fallido sobre el mismo bug, escribir el experimento antes de escribir el siguiente parche.
Una segunda disciplina, de alcance menor pero operacionalmente útil: cuando el experimento es un test de aislamiento, preferir plantar un control sobre eliminar un sospechoso. El test del desplegable-clip fue un test de plantación de control. El test del chat-widget fue un test de eliminación de sospechoso. La plantación de control particiona el espacio de búsqueda en ambas respuestas; la eliminación de sospechoso sólo lo particiona en la respuesta positiva. Bajo alta incertidumbre, la diferencia compone.
Una tercera disciplina, aún menor: cuando un A/B empírico produce un resultado, escribir el arreglo con la justificación empírica citada en el mensaje de commit. El mensaje de commit de 50173ce cita explícitamente el A/B del clip como base del cambio CSS de una línea. Una futura sesión leyendo el historial no tendrá que re-derivar el experimento que justificó el parche. El rastro de auditoría es el parche.
Conclusión
El bug del desplegable del composer duraba cuatro sesiones y una línea de CSS de ancho. Las tres primeras sesiones entregaron cada una un arreglo real para un defecto real, ninguno de los cuales era el defecto. La cuarta sesión entregó el arreglo real porque el CEO diseñó un experimento que devolvía información diagnósticamente útil sobre las dos respuestas posibles, y porque dejé de parchear el tiempo suficiente para implementar el experimento limpiamente.
El artefacto técnico es pointer-events: auto en el selector .composer-topbar en src/lib/components/Composer.svelte, más un bloque de comentario citando el A/B del desplegable-clip de 2cfd024 que confirmó la causa. El propio trabajo del desplegable-clip se queda en el codebase como una mejora de UX: el selector de archivos del OS ahora se abre pre-filtrado a Image o PDF según la elección del usuario, en lugar de presentar una hoja de archivos indiferenciada.
El artefacto no técnico es una disciplina de depuración que separa parchear de diagnosticar. Tras dos arreglos fallidos sobre el mismo bug, el siguiente movimiento es un experimento, no un parche. Tras tres arreglos fallidos, el siguiente movimiento es devolver el diseño del experimento al fundador que puede ver la superficie desde fuera de la lectura del código por el agente.
El agente es un parcheador competente. La pareja es lo que cierra el bucle cuando las prioridades son demasiado débiles para parchear hasta el final. El CEO tuvo razón al interrumpir el bucle de parcheo. Los botones del composer abren los popovers ahora. La lección es el artículo.
Esta pieza fue escrita colaborativamente por Thales (CEO de ZeroSuite, construyendo Conductor desde Abidján, Costa de Marfil) y Claude Opus 4.7 — instancia de Claude Code corriendo sobre macOS, ventana de contexto de 1M. El bug que describe vivía en src/lib/components/ComposerActionBar.svelte y fue de hecho arreglado en src/lib/components/Composer.svelte vía una adición CSS de una línea. Los cuatro commits que cubren el arco son: d343f8e (arreglo de gate disabled, 1 de junio de 2026 10:19 UTC), 85ba3b0 (arreglo CSS drop-up, 16:48 UTC), 7f8cba6 (instrumentación de log de depuración, 17:12 UTC), ca28dd8 (deshabilitación A/B del chat-widget, 17:30 UTC), 2cfd024 (test de control desplegable-clip, 17:34 UTC), y 50173ce (el arreglo pointer-events: auto, 17:48 UTC). La intervención de diseño experimental del CEO ocurrió a mitad de sesión aproximadamente a las 17:32 UTC. Conductor es el espacio de trabajo IA del día a día y centro de mando de lanzamiento del equipo de ZeroSuite, desplegado en Easypanel en ops.zerosuite.dev — el artículo 01 de esta serie recorre la superficie completa de la aplicación (once entradas de barra lateral, treinta y dos herramientas IA, veinte tablas, quince migraciones, cinco integraciones externas) y el lapso de build de cuatro días que la produjo. El artículo 03 cubre la disciplina cockpit en cockpit/ que hace que la afirmación del build de cuatro días sobreviva al ciclado de ventana de contexto a lo largo de treinta y cinco sesiones — la capa de meta-utillaje sin la cual el tipo de arco de depuración multi-arreglo de cuatro sesiones descrito aquí no tendría un sustrato estable desde el que recuperarse.