1. Por qué gestionar la apariencia
Ahora mismo tu widget probablemente se ve como un «componente React normal»: un div, una lista de elementos, un par de botones. En la web normal suele bastar. En ChatGPT, sin embargo, hay un matiz: tu UI vive dentro del chat, donde el usuario ya tiene mucho contexto visual — mensajes, otras Apps, interfaz de voz, además de limitaciones por el tamaño del contenedor.
Es importante recordar dos cosas.
En primer lugar, el widget tiene un modo de visualización (displayMode): inline, fullscreen, a veces PiP. Del modo dependen el área disponible, el comportamiento del scroll y las expectativas del usuario.
En segundo lugar, la plataforma comunica al widget las restricciones de altura (maxHeight) y el tema (theme). Si las ignoras y dibujas algo del tamaño de Notion dentro de un solo mensaje, el chat se convierte en un «agujero negro», donde todo se pierde en un enorme iframe. OpenAI recomienda expresamente hacer una UI concisa y respetar los colores/tipografía del sistema.
Un escenario típico con GiftGenius ilustra bien cómo funciona en la práctica. El usuario pide: «Elige un regalo para un amigo por hasta $50». ChatGPT lanza GiftGenius, que en modo inline muestra tarjetas compactas de regalos y un par de botones. El usuario hace clic en «Más información» — el widget solicita fullscreen y allí muestra filtros, descripción detallada, reseñas. Cuando se tramita la compra, se puede mostrar un pequeño PiP/modal con el estado «Procesando el pedido…» sin cubrir todo el chat.
Nuestro objetivo en esta lección es aprender a:
- entender cuál es el displayMode actual y tratarlo adecuadamente;
- cambiar el modo bajo demanda (inline ↔ fullscreen, a veces PiP);
- respetar maxHeight y evitar el «doble scroll»;
- adaptar estilos al tema claro/oscuro y al ancho de pantalla;
- construir un layout que se sienta «nativo» dentro de ChatGPT.
2. Modos de displayMode: inline, fullscreen, PiP
Empecemos por los conceptos. displayMode es el estado del contenedor de tu widget en ChatGPT. Viene de la plataforma (a través de window.openai.displayMode o el hook useDisplayMode) y puede tomar valores como "inline", "fullscreen", "pip".
Inline
Inline es el modo por defecto. El widget se inserta directamente en el flujo de mensajes como otro «bloque» entre respuestas de texto. El ancho está limitado por la columna del chat (en escritorio ~700–800 px, en móvil — el ancho de la pantalla), y la altura es dinámica, pero no infinita.
Inline es ideal para:
- vistas cortas y autosuficientes: tarjetas de regalos, lista de opciones, resumen de búsquedas;
- una o dos acciones: «Seleccionar», «Cancelar», «Mostrar más».
Para GiftGenius este es el modo principal: el usuario escribe una petición y tú muestras 3–5 tarjetas de regalos con botones sin acaparar toda la pantalla.
Fullscreen (Canvas)
Fullscreen (o canvas) es el modo en el que tu widget ocupa gran parte del área visible. El chat no desaparece: la línea de entrada sigue disponible, pero la atención principal recae en tu UI.
Activar fullscreen tiene sentido cuando:
- hay muchos campos de entrada o un asistente complejo (checkout, filtros complejos, ajustes);
- necesitas mostrar tablas grandes, mapas, comparar decenas de elementos;
- inline ya no cabe y empieza a parecer un mini Excel de 700 px de alto.
En GiftGenius, fullscreen sirve para ofrecer al usuario filtros completos, ordenación, descripciones detalladas y, posiblemente, varias pestañas.
PiP / Modal
PiP (picture-in-picture) y los modales son pequeñas ventanas «flotantes» sobre el contenido principal. En implementaciones actuales del Apps SDK, PiP a menudo se implementa o bien como un modo especial de displayMode, o bien como una ventana modal mediante requestModal().
Son útiles cuando:
- hay que mostrar el estado de un proceso largo (procesamiento de un pedido, render de vídeo);
- hay que preguntar algo pequeño sin interrumpir el flujo principal (confirmación rápida);
- quieres permitir al usuario «mantener el widget a la vista» mientras sigue chateando.
En GiftGenius puede ser un panel pequeño «Tramitando el pedido… 30%» con un botón «Cancelar».
Pequeña comparativa
Tabla para una percepción visual:
| Modo | Dónde reside | Casos típicos | Limitaciones |
|---|---|---|---|
|
en el flujo de mensajes | Listas, tarjetas, uno o dos botones | Altura limitada, ancho estrecho |
|
sobre el chat / a un lado | Asistentes, formularios complejos, tablas | Exige un layout y navegación bien pensados |
| PiP / modal | capa flotante | Estado, mini formularios, vídeo | Muy poco espacio, todo debe ser grande y simple |
Es importante no tratar fullscreen como «la aplicación real» e inline como un «preview». Es la misma App, solo en «posturas» diferentes.
3. Hooks para trabajar con el modo: useDisplayMode, useRequestDisplayMode, useRequestModal
Ahora que entendemos qué son inline/fullscreen/PiP desde el punto de vista de UX, veamos cómo trabajar con ellos desde el código mediante hooks del Apps SDK.
En lugar de leer window.openai.displayMode directamente, usamos un hook del boilerplate que está suscrito a cambios y te evita los bailes rituales con los eventos del SDK. Una interfaz típica sería:
// pseudotipos; confirme los nombres reales según la plantilla
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
function useDisplayMode() {
// devuelve el modo actual
return { displayMode: 'inline' as DisplayMode };
}
function useRequestDisplayMode() {
// función para solicitar el cambio de modo
return {
requestDisplayMode: (mode: DisplayMode) => {
/* llama a window.openai.requestDisplayMode */
},
};
}
Hagamos un componente sencillo que muestra el modo actual y ofrece un botón «Expandir / Contraer»:
import { useDisplayMode, useRequestDisplayMode } from '@/apps-sdk';
export function DisplayModeDebug() {
const { displayMode } = useDisplayMode();
const { requestDisplayMode } = useRequestDisplayMode();
const toggle = () => {
requestDisplayMode(displayMode === 'inline' ? 'fullscreen' : 'inline');
};
return (
<div className="text-xs text-gray-500 flex gap-2 items-center">
<span>Modo: {displayMode}</span>
<button onClick={toggle} className="underline">
Cambiar
</button>
</div>
);
}
En Apps reales sueles ocultar estos elementos «de depuración», pero en Dev Mode este componente ayuda mucho a sentir cómo se comporta el widget al alternar.
Inline vs Fullscreen con subcomponentes distintos
Un error habitual es intentar servir todos los modos con el mismo layout y lanzar en el JSX un montón de if (displayMode === ...). Mucho más cómodo para el cerebro es separar la presentación:
import { useDisplayMode } from '@/apps-sdk';
import { GiftListInline } from './GiftListInline';
import { GiftListFullscreen } from './GiftListFullscreen';
export function GiftWidget() {
const { displayMode } = useDisplayMode();
if (displayMode === 'fullscreen') {
return <GiftListFullscreen />;
}
return <GiftListInline />;
}
Así el código se lee como «si es fullscreen — aquí va el asistente complejo; en caso contrario — inline compacto». Y cada subcomponente se puede estilizar por separado según sus limitaciones. Este enfoque es precisamente el recomendado: separar los modos en subcomponentes independientes en lugar de un enorme if/else en un solo componente.
Modales: useRequestModal
Si el boilerplate ofrece el hook useRequestModal, su interfaz suele ser similar:
const { requestModal } = useRequestModal();
// requestModal({ title }) o algo parecido.
Los modales se parecen un poco a fullscreen, pero no lo sustituyen: fullscreen es para escenarios grandes; un modal, para un paso corto (confirmar acción, introducir un cupón, etc.).
4. Control de tamaños: maxHeight, scroll y notifyIntrinsicHeight()
El segundo eje importante es la altura. La plataforma le dice al widget: «Esta es la altura máxima disponible». Este límite puede leerse en window.openai.maxHeight o mediante el hook useMaxHeight.
Por qué no basta con poner «height: 5000px»
Si ignoras maxHeight y pones una altura fija enorme, ChatGPT se verá obligado a recortar tu contenido. O bien dará al usuario doble scroll: externo (del chat) e interno (de tu widget). Es una mala UX: el usuario tiene que adivinar dónde hacer scroll para llegar al botón adecuado.
Estrategia correcta:
- Leer el límite maxHeight.
- Construir el layout de forma que el scroll principal siga siendo el del chat (especialmente en inline).
- En fullscreen se puede permitir algo de scroll interno, pero con cuidado.
useMaxHeight y limitar el contenedor
Escribamos un wrapper sencillo que establezca el máximo de altura para el contenedor raíz:
import { useMaxHeight } from '@/apps-sdk';
export function WidgetContainer(props: { children: React.ReactNode }) {
const { maxHeight } = useMaxHeight(); // por ejemplo, 600
return (
<div
style={{ maxHeight }}
className="overflow-y-auto p-4 bg-background border border-border rounded-xl"
>
{props.children}
</div>
);
}
Aquí limitamos honestamente la altura y activamos el scroll vertical dentro del contenedor, pero con mesura. En la práctica, en inline es mejor evitar mucho scroll interno y, en lugar de listas enormes, mostrar una parte de los datos con un botón «Mostrar más» o proponer fullscreen.
Altura dinámica y notifyIntrinsicHeight()
Otro matiz: tu contenido puede cambiar de tamaño con el tiempo. Por ejemplo, primero muestras un spinner «Cargando regalos…», luego — una lista de 10 tarjetas, después el usuario pliega/despliega filtros. Para que ChatGPT reserve el espacio correcto para el widget y no lo recorte, al cambiar la altura debes notificar al host el nuevo valor. Para eso está notifyIntrinsicHeight().
En el boilerplate esto suele venir envuelto en un hook tipo useAutoResize. Puede implementarse más o menos así:
import { useEffect, useRef } from 'react';
import { useNotifyIntrinsicHeight } from '@/apps-sdk';
export function useAutoResize() {
const ref = useRef<HTMLDivElement | null>(null);
const { notifyIntrinsicHeight } = useNotifyIntrinsicHeight();
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
notifyIntrinsicHeight(entry.contentRect.height);
}
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [notifyIntrinsicHeight]);
return ref;
}
Y lo usamos así:
export function GiftListInline() {
const containerRef = useAutoResize();
return (
<div ref={containerRef}>
{/* tu contenido */}
</div>
);
}
La idea es simple: cuando tu div raíz cambia de altura, llamas al API del SDK y ChatGPT ajusta el contenedor. Este patrón es directamente recomendado por desarrolladores con experiencia: un «wrapper auto‑resizable» alrededor de todo el contenido.
Pequeño esquema
Imaginémoslo como un diagrama de flujo:
flowchart TD
A[El contenido del widget cambió] --> B[ResizeObserver detecta la nueva altura]
B --> C["Llamada a notifyIntrinsicHeight(newHeight)"]
C --> D[ChatGPT aumenta/disminuye el contenedor]
D --> E[El usuario ve un scroll limpio sin recortes]
Con tamaños y altura resueltos: el widget no debe salirse del espacio asignado ni imponer al usuario el reto del doble scroll.
5. Tema (theme), colores y bordes: cómo hacer el widget «nativo»
Si displayMode y maxHeight determinan cuánto espacio tenemos, el tema (theme) y la paleta determinan cómo se ve ese trozo de interfaz dentro del chat.
ChatGPT soporta al menos tema claro y oscuro. La plataforma lo pasa a tu widget mediante window.openai.theme y/o en _meta["openai/theme"], y en el boilerplate de React hay un hook useOpenAiGlobal("theme") o algo como useTheme.
La idea principal: tu UI debe adaptarse al tema, no imponer el suyo.
Obtener el tema
Ejemplo de un hook sencillo:
import { useOpenAiGlobal } from '@/apps-sdk';
export function useThemeMode() {
const theme = useOpenAiGlobal<'light' | 'dark'>('theme') ?? 'light';
return { theme };
}
En el componente:
export function ThemedCard(props: { children: React.ReactNode }) {
const { theme } = useThemeMode();
const className =
theme === 'dark'
? 'bg-slate-900 text-slate-100 border-slate-700'
: 'bg-white text-slate-900 border-slate-200';
return (
<div className={`rounded-xl border p-4 ${className}`}>
{props.children}
</div>
);
}
En un proyecto real, probablemente uses Tailwind con darkMode: 'class' y cuelgues la clase dark del contenedor raíz del widget. Pero la esencia no cambia: el tema llega desde el Apps SDK, no vive por su cuenta.
Colores, bordes y tipografía
Según las guías de OpenAI:
- usa tipografías del sistema y una tipografía cuidada;
- no sobreescribas los colores del sistema de forma agresiva;
- el widget debe ser un elemento «nativo» del chat, no una landing independiente con un degradado chillón.
Un patrón adecuado para el contenedor en GiftGenius:
export function GiftCard(props: { title: string; price: string }) {
return (
<div className="rounded-xl border border-border bg-background p-3 flex flex-col gap-2">
<div className="font-medium text-foreground">{props.title}</div>
<div className="text-sm text-muted-foreground">{props.price}</div>
<button className="self-start px-3 py-1 text-sm rounded-full bg-primary text-primary-foreground">
Elegir
</button>
</div>
);
}
Aquí se presupone que bg-background, border-border, text-foreground, bg-primary, etc., son variables/clases utility de CSS vinculadas al tema de ChatGPT. Este enfoque también está descrito en las recomendaciones: usar variables y clases ligadas al tema, no fijar colores rígidos.
6. Layout y adaptabilidad: desktop, mobile, PiP
El tercer eje es el ancho y el dispositivo. Simplificando mucho, la apariencia del widget viene determinada por el modo (displayMode), la altura disponible (maxHeight) y el ancho disponible (desktop/mobile/PiP).
En esta sección tratamos el tercer parámetro. En desktop, un widget inline tiene un ancho; en móvil, otro; en PiP hay poquísimo espacio. El Apps SDK transmite señales como userAgent, safeArea, a veces el tamaño del contenedor, que pueden leerse con useOpenAiGlobal.
Principios generales
Algunos principios importantes:
Primero, no confíes en un ancho fijo. La pantalla del usuario puede ser estrecha (teléfono) o ancha (gran escritorio). Por eso, es mejor construir el layout con flex/grid y auto-fit que con un width: 400px rígido.
Segundo, evita el scroll horizontal. Si tu tabla o tarjetas no caben, es mejor pasar a fullscreen o mostrar una versión recortada. Aunque puedes usar un carrusel con slides.
Tercero, ten en cuenta que PiP/modales suelen ser muy estrechos, y no se pueden meter allí formularios grandes — al usuario le resultará incómodo interactuar.
Estos puntos están subrayados en la documentación: adaptabilidad, safeArea, diferencias desktop vs mobile y el peligro de layouts sobrecargados.
Layouts distintos para inline y fullscreen
Volvamos a GiftGenius. La lista de regalos en inline y fullscreen puede verse muy diferente. Hagamos dos componentes.
Inline compacto: máximo 3 tarjetas, una columna en móvil y dos en pantallas anchas.
export function GiftListInline() {
const gifts = useGiftData(); // hook hipotético, tomamos de toolOutput
return (
<WidgetContainer>
<h2 className="text-base font-semibold mb-3">
Selección de regalos
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{gifts.slice(0, 3).map(gift => (
<GiftCard
key={gift.id}
title={gift.title}
price={`${gift.price} $`}
/>
))}
</div>
{gifts.length > 3 && (
<p className="mt-3 text-xs text-muted-foreground">
Se muestran las 3 primeras opciones. Expande el widget para ver todas.
</p>
)}
</WidgetContainer>
);
}
Y la versión fullscreen: rejilla, filtros, más tarjetas.
export function GiftListFullscreen() {
const gifts = useGiftData();
const [query, setQuery] = useState('');
const filtered = gifts.filter(g =>
g.title.toLowerCase().includes(query.toLowerCase()),
);
return (
<div className="h-full flex flex-col gap-4 p-4">
<header className="flex gap-2 items-center">
<h1 className="text-lg font-semibold flex-1">
Regalos para ti
</h1>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Filtrar por nombre"
className="px-2 py-1 text-sm border rounded-md flex-1"
/>
</header>
<main className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map(gift => (
<GiftCard
key={gift.id}
title={gift.title}
price={`${gift.price} $`}
/>
))}
</div>
</main>
</div>
);
}
Aquí permitimos scroll vertical interno del contenido fullscreen (overflow-y-auto en main), lo cual es normal en modo de pantalla completa. La versión inline, como recomiendan las guías, sigue siendo compacta y «legible en 2 segundos».
Esquema: comportamiento según el modo
Para afianzar, dibujemos un diagrama simple:
stateDiagram-v2
[*] --> Inline
Inline: 3 tarjetas, mínimo texto
Inline --> Fullscreen: Clic "Expandir" / "Mostrar todo"
Fullscreen: Rejilla, filtros, muchos datos
Fullscreen --> Inline: Botón "Cerrar" / acción del host
Fullscreen --> PiP: Operación larga, mostrar progreso
PiP: Panel pequeño de estado
PiP --> Inline: Operación finalizada, mostramos el resultado
Este escenario se parece mucho a los patrones de UX descritos: inline como teaser, fullscreen como herramienta de trabajo y PiP como indicador de proceso.
7. Práctica: dos modos del mismo widget
Hora de aterrizarlo en código. Como práctica en el marco de esta lección, tiene sentido hacer dos pasos en la app de ejemplo.
Paso 1. Widget inline con tarjeta
Amplía el GiftGenius actual para que, en modo inline, el widget:
- muestre el encabezado «Selección de regalos»;
- muestre hasta tres tarjetas de regalos del toolOutput;
- muestre la nota «Expande el widget para ver todas» si hay más de tres regalos;
- ajuste la altura con cuidado mediante useAutoResize y notifyIntrinsicHeight().
Los estilos deben apoyarse en el tema: usa clases o variables vinculadas a theme, no colores fijos.
Paso 2. Versión fullscreen con formulario
Después añade la representación fullscreen que:
- muestre un encabezado + búsqueda por nombre;
- liste todos los regalos en una rejilla;
- permita scroll vertical dentro del área principal;
- ofrezca un botón «Volver al chat» (que invoque requestDisplayMode('inline')).
La composición puede verse así:
export function GiftGeniusWidget() {
const { displayMode } = useDisplayMode();
return (
<>
<DisplayModeDebug />
{displayMode === 'fullscreen' ? (
<GiftListFullscreen />
) : (
<GiftListInline />
)}
</>
);
}
En ChatGPT Dev Mode podrás alternar manualmente el modo o solicitar fullscreen por programa al hacer clic en «Mostrar todo» en la versión inline (mediante useRequestDisplayMode). Este ejercicio afianzará la comprensión de cómo la misma App puede verse y comportarse de forma diferente según el displayMode.
8. Errores típicos al gestionar la apariencia del widget
Antes de seguir con el curso, fijemos algunos tropiezos típicos relacionados con displayMode, tamaños, tema y layout. Si los evitas desde el principio, la vida con el Apps SDK será mucho más agradable.
Error n.º 1: Ignorar displayMode e intentar «forzar» todo a parecer fullscreen.
A veces los desarrolladores dibujan un layout pesado (casi como un SPA aparte) que apenas cabe en inline. El resultado es que el usuario ve un Notion en miniatura con scrolls y un millón de elementos. El enfoque correcto es diseñar vistas distintas para modos distintos y respetar que inline es un formato compacto y «de una sola pantalla».
Error n.º 2: Altura fija enorme y doble scroll.
Poner height: 800px y olvidarse de maxHeight conduce a que tu widget sea recortado o genere scroll interno y externo a la vez. El usuario empieza a «cazar» la barra de desplazamiento correcta, lo que empeora mucho la UX. En su lugar, hay que leer maxHeight, limitar con max-height y, cuando cambie la altura, notificarlo con notifyIntrinsicHeight().
Error n.º 3: Ignorar el tema e intentar «recolorearlo todo con la marca».
Si impones tus propias tipografías, fondos, degradados chillones y ignoras por completo el tema claro/oscuro de ChatGPT, rompes la unidad visual de la plataforma. Las guías dicen claramente: usa colores y tipografías del sistema, y lleva la marca con acentos discretos (botón, icono, logotipo). Vigila la theme mediante un hook y ajusta la paleta.
Error n.º 4: UI demasiado compleja en PiP/modales.
Intentar meter un formulario completo con muchos campos en una ventanita PiP es un callejón sin salida. Ahí solo caben casos muy simples: progreso de proceso, uno o dos botones, un campo de entrada. Todo lo demás — candidatos a fullscreen.
Error n.º 5: Maquetar rígidamente para 800 px y no probar en móviles.
Maquetar rígidamente para 800 px y pensar que «de alguna forma entrará en el teléfono». En la realidad, el cliente móvil de ChatGPT tiene otro ancho y comportamiento, y PiP es aún más estrecho. No olvides userAgent/safeArea, usa grid/flex sin ancho fijo y mira tu widget al menos una vez con un layout estrecho.
Error n.º 6: Trabajar directamente con window.openai sin hooks.
Formalmente puedes escribir const mode = window.openai.displayMode, pero entonces tendrás que suscribirte tú a los eventos, pensar en las actualizaciones de React y cazar bugs si el SDK cambia algo. Los hooks (useDisplayMode, useMaxHeight, useOpenAiGlobal, useRequestDisplayMode) existen precisamente para ocultar esta rutina y mantener el código más limpio. Mejor úsalos y vive tranquilo.
GO TO FULL VERSION