CodeGym /Cours /ChatGPT Apps /Gestion de l’apparence :

Gestion de l’apparence : displayMode, maxHeight, borders, theme, layout

ChatGPT Apps
Niveau 3 , Leçon 1
Disponible

1. Pourquoi gérer l’apparence

À l’heure actuelle, votre widget ressemble probablement à un « composant React normal » : un div, une liste d’éléments, deux boutons. Sur le web classique, cela suffit souvent. Dans ChatGPT, il y a un nuance : votre UI vit à l’intérieur du chat, où l’utilisateur a déjà beaucoup de contexte visuel — messages, autres Apps, interface vocale, plus des contraintes de taille de conteneur.

Deux choses sont importantes à garder en tête.

Premièrement, le widget a un mode d’affichage (displayMode) : inline, fullscreen, parfois PiP. Le mode influe sur la surface disponible, le comportement du défilement et les attentes de l’utilisateur.

Deuxièmement, la plateforme transmet au widget des contraintes de hauteur (maxHeight) et le thème (theme). Si vous les ignorez et dessinez quelque chose de la taille de Notion dans un seul message, le chat devient un « trou noir » où tout se perd dans un énorme iframe. OpenAI recommande explicitement de faire une UI concise et de respecter les couleurs/typographies système.

Le scénario typique de GiftGenius illustre bien comment cela fonctionne en pratique. L’utilisateur demande : « Trouve un cadeau pour un ami jusqu’à 50 $ ». ChatGPT lance GiftGenius, qui en mode inline affiche des cartes de cadeaux compactes et quelques boutons. L’utilisateur clique sur « En savoir plus » — le widget demande le plein écran et affiche alors des filtres, une description détaillée, des avis. Lors du passage de commande, on peut afficher un petit PiP/modale avec le statut « Nous traitons la commande… », sans recouvrir tout le chat.

Notre objectif dans cette leçon est d’apprendre à :

  • comprendre quel est le displayMode en cours, et s’y adapter correctement ;
  • basculer le mode à la demande (inline ↔ fullscreen, parfois PiP) ;
  • respecter maxHeight et éviter le « double défilement » ;
  • adapter les styles au thème clair/sombre et à la largeur d’écran ;
  • construire un layout qui paraît « natif » dans ChatGPT.

2. Modes displayMode : inline, fullscreen, PiP

Commençons par les notions. displayMode est l’état du conteneur de votre widget dans ChatGPT. Il vient de la plateforme (via window.openai.displayMode ou le hook useDisplayMode) et peut prendre des valeurs comme "inline", "fullscreen", "pip".

Inline

Inline est le mode par défaut. Le widget est inséré directement dans le flux des messages comme un « bloc » supplémentaire entre des réponses textuelles. La largeur est limitée par la colonne du chat (sur desktop ~700–800 px, sur téléphone — la largeur de l’écran), et la hauteur est dynamique, mais pas infinie.

Inline est idéal pour :

  • des vues courtes et autonomes : cartes de cadeaux, liste d’options, résumé de recherche ;
  • une ou deux actions : « Choisir », « Annuler », « Afficher plus ».

Pour GiftGenius, c’est le mode principal : l’utilisateur saisit une requête et vous affichez 3–5 cartes de cadeaux avec des boutons, sans accaparer tout l’écran.

Fullscreen (Canvas)

Fullscreen (ou canvas) est le mode où votre widget occupe la majeure partie de la zone visible. Le chat ne disparaît pas pour autant : la zone de saisie reste accessible, mais l’attention principale est sur votre UI.

Activer le fullscreen a du sens lorsque :

  • il y a beaucoup de champs de saisie ou un wizard complexe (passage de commande, filtres élaborés, réglages) ;
  • il faut afficher de grands tableaux, des cartes, comparer des dizaines d’éléments ;
  • le inline ne tient plus et commence à ressembler à un mini‑Excel de 700 px de haut.

Dans GiftGenius, le fullscreen est utile pour proposer de vrais filtres, du tri, des descriptions détaillées, éventuellement plusieurs onglets.

PiP / Modal

PiP (picture‑in‑picture) et les modales sont de petites fenêtres « flottantes » au‑dessus du contenu principal. Dans les implémentations actuelles de l’Apps SDK, PiP est souvent implémenté soit comme un mode particulier de displayMode, soit comme une fenêtre modale via requestModal().

Ils sont utiles quand :

  • il faut afficher le statut d’un long processus (traitement d’une commande, rendu vidéo) ;
  • il faut poser une petite question sans interrompre le flux principal (confirmation rapide) ;
  • vous voulez laisser la possibilité à l’utilisateur de « garder le widget sous les yeux » tout en continuant le chat.

Dans GiftGenius, cela peut être un petit panneau « Nous traitons la commande… 30 % » avec un bouton « Annuler ».

Petite comparaison

Un tableau pour la perception visuelle :

Mode Où il s’affiche Cas typiques Contraintes
inline
dans le flux des messages Listes, cartes, un ou deux boutons Hauteur limitée, largeur étroite
fullscreen
au‑dessus du chat / sur le côté Assistants, formulaires complexes, tableaux Nécessite un layout et une navigation réfléchis
PiP / modal calque flottant Statut, mini‑formulaires, vidéo Très peu d’espace, tout doit être grand et simple

Il ne faut pas considérer le fullscreen comme une « vraie application » et le inline comme une « preview ». C’est la même App, simplement dans des « postures » différentes.

3. Hooks pour travailler avec le mode : useDisplayMode, useRequestDisplayMode, useRequestModal

Maintenant que nous avons clarifié ce qu’est inline/fullscreen/PiP d’un point de vue UX, voyons comment les manipuler dans le code via les hooks de l’Apps SDK.

Au lieu de lire directement window.openai.displayMode, nous utilisons un hook du template, abonné aux changements et qui vous évite les danses rituelles avec les événements de l’SDK. Une interface typique ressemble à ceci :

// pseudotypes, vérifiez les noms réels dans le template
type DisplayMode = 'inline' | 'fullscreen' | 'pip';

function useDisplayMode() {
  // retourne le mode actuel
  return { displayMode: 'inline' as DisplayMode };
}

function useRequestDisplayMode() {
  // fonction de requête pour changer de mode
  return {
    requestDisplayMode: (mode: DisplayMode) => {
      /* appelle window.openai.requestDisplayMode */
    },
  };
}

Faisons un composant simple qui affiche le mode actuel et propose un bouton « Déployer / Réduire » :

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>Mode : {displayMode}</span>
      <button onClick={toggle} className="underline">
        Basculer
      </button>
    </div>
  );
}

Dans les Apps réelles, vous cachez généralement ces éléments « d’outillage », mais en Dev Mode, un tel composant aide beaucoup à sentir le comportement du widget lors des bascules.

Inline vs Fullscreen avec des sous‑composants différents

Une erreur fréquente consiste à vouloir couvrir tous les modes avec le même layout et à saupoudrer le JSX d’une tonne de if (displayMode === ...). C’est beaucoup plus confortable pour le cerveau de séparer la présentation :

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 />;
}

Ainsi, le code se lit comme « si fullscreen — voici un wizard complexe, sinon — un inline compact ». Et chaque sous‑composant peut être stylé séparément selon ses contraintes. Cette approche est d’ailleurs recommandée : séparer les modes en sous‑composants dédiés au lieu d’un énorme if/else dans un seul composant.

Modales : useRequestModal

Si le template fournit le hook useRequestModal, son interface ressemble en général à :

const { requestModal } = useRequestModal();
// requestModal({ title }) ou quelque chose d’approchant.

Les modales ressemblent un peu au fullscreen, mais ne le remplacent pas : le fullscreen sert aux grands scénarios, la modale à une étape courte (confirmer une action, saisir un code promo, etc.).

4. Contrôle des tailles : maxHeight, défilement et notifyIntrinsicHeight()

Deuxième axe important : la hauteur. La plateforme dit au widget : « Voici la hauteur maximale disponible ». Cette limite peut être lue dans window.openai.maxHeight ou via le hook useMaxHeight.

Pourquoi ne pas simplement mettre « height: 5000px »

Si vous ignorez maxHeight et fixez une hauteur énorme, ChatGPT sera contraint de couper votre contenu. Ou il donnera à l’utilisateur un double défilement : l’externe (du chat) et l’interne (de votre widget). C’est une mauvaise UX : l’utilisateur doit deviner où faire défiler pour atteindre le bon bouton.

La bonne stratégie est la suivante :

  1. Lire la limite maxHeight.
  2. Construire le layout de façon à laisser le défilement principal au chat (surtout en inline).
  3. En fullscreen, on peut se permettre un peu de défilement interne, mais avec parcimonie.

useMaxHeight et limitation du conteneur

Écrivons un simple wrapper qui définit la hauteur maximale pour le conteneur racine :

import { useMaxHeight } from '@/apps-sdk';

export function WidgetContainer(props: { children: React.ReactNode }) {
  const { maxHeight } = useMaxHeight(); // par exemple, 600

  return (
    <div
      style={{ maxHeight }}
      className="overflow-y-auto p-4 bg-background border border-border rounded-xl"
    >
      {props.children}
    </div>
  );
}

Ici, nous limitons honnêtement la hauteur et activons le défilement vertical à l’intérieur du conteneur, mais dans des limites raisonnables. En pratique, en inline, évitez un grand défilement interne et, au lieu de longues listes, affichez une partie des données avec un bouton « Afficher plus » ou proposez le fullscreen.

Hauteur dynamique et notifyIntrinsicHeight()

Autre nuance : votre contenu peut changer de taille dans le temps. Par exemple, vous affichez d’abord un spinner « Chargement des cadeaux… », puis une liste de 10 cartes, puis l’utilisateur replie/déplie des filtres. Pour que ChatGPT réserve correctement l’espace pour le widget et ne le coupe pas, il faut, lors des changements de hauteur, signaler au host la nouvelle valeur. Pour cela, il y a notifyIntrinsicHeight().

Dans le template, c’est souvent encapsulé dans un hook du type useAutoResize. On peut l’implémenter à peu près ainsi :

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;
}

Et l’utiliser :

export function GiftListInline() {
  const containerRef = useAutoResize();

  return (
    <div ref={containerRef}>
      {/* votre contenu */}
    </div>
  );
}

L’idée est simple : quand votre div racine change de hauteur, vous appelez l’API de l’SDK et ChatGPT ajuste le conteneur. Ce pattern est directement recommandé par les développeurs expérimentés : un « wrapper auto‑resize » autour de tout le contenu.

Petit schéma

Représentons cela sous forme d’organigramme :

flowchart TD
    A[Le contenu du widget a changé] --> B[ResizeObserver détecte la nouvelle hauteur]
    B --> C["Appel de notifyIntrinsicHeight(newHeight)"]
    C --> D[ChatGPT agrandit/réduit le conteneur]
    D --> E[L’utilisateur voit un défilement propre sans coupures]

Nous en avons terminé avec les tailles et la hauteur : le widget ne doit pas déborder de l’espace alloué ni transformer l’expérience en chasse au bon ascenseur.

5. Thème (theme), couleurs et bordures : comment rendre le widget « natif »

Si displayMode et maxHeight déterminent combien d’espace nous avons, le thème (theme) et la palette déterminent comment ce morceau d’interface s’intègre dans le chat.

ChatGPT prend en charge au moins les thèmes clair et sombre. La plateforme transmet cela à votre widget via window.openai.theme et/ou dans _meta["openai/theme"], et le template React propose un hook useOpenAiGlobal("theme") ou quelque chose comme useTheme.

L’idée principale : votre UI doit s’adapter au thème, pas imposer le sien.

Récupérer le thème

Exemple de hook simple :

import { useOpenAiGlobal } from '@/apps-sdk';

export function useThemeMode() {
  const theme = useOpenAiGlobal<'light' | 'dark'>('theme') ?? 'light';
  return { theme };
}

Dans un composant :

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>
  );
}

Dans un projet réel, vous utilisez probablement Tailwind avec darkMode: 'class' et appliquez la classe dark au conteneur racine du widget. Mais l’essentiel ne change pas : le thème vient de l’Apps SDK, il ne vit pas en autarcie.

Couleurs, bordures et typographie

Selon les guidelines d’OpenAI :

  • utilisez les polices système et une typographie soignée ;
  • n’écrasez pas agressivement les couleurs système ;
  • le widget doit être un élément « natif » du chat, et non une landing autonome avec un dégradé criard.

Bon pattern pour le conteneur 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">
        Choisir
      </button>
    </div>
  );
}

Ici, on suppose que bg-background, border-border, text-foreground, bg-primary, etc. sont des variables CSS/utility classes liées au thème de ChatGPT. Cette approche est également décrite dans les recommandations : utiliser des variables et classes liées au thème, plutôt que de figer des couleurs.

6. Layout et adaptativité : desktop, mobile, PiP

Troisième axe : la largeur et l’appareil. Pour simplifier, l’apparence du widget est déterminée par le mode (displayMode), la hauteur disponible (maxHeight) et la largeur disponible (desktop/mobile/PiP).

Dans cette section, attaquons le troisième paramètre. Sur desktop, un widget inline a une largeur, sur mobile — une autre ; en PiP, l’espace est très réduit. L’Apps SDK transmet des signaux comme userAgent, safeArea, parfois la taille du conteneur, lisibles via useOpenAiGlobal.

Principes généraux

Quelques principes importants :

Premièrement, n’escomptez pas une largeur fixe. L’écran de l’utilisateur peut être étroit (téléphone) ou large (grand desktop). Construisez donc le layout avec flex/grid et auto‑fit, plutôt qu’avec un width: 400px rigide.

Deuxièmement, évitez le défilement horizontal. Si votre tableau ou vos cartes ne tiennent pas, mieux vaut passer en fullscreen ou afficher une version raccourcie. Une carrousel avec des slides est aussi possible.

Troisièmement, gardez à l’esprit que PiP/modales sont souvent très étroits, et qu’on ne peut pas y mettre un grand formulaire — ce serait pénible à utiliser.

Ces points sont soulignés dans la documentation : adaptativité, safeArea, différence desktop vs mobile et danger des layouts surchargés.

Layouts distincts pour inline et fullscreen

Revenons à GiftGenius. La liste des cadeaux en inline et en fullscreen peut être très différente. Faisons deux composants.

Inline compact : maximum 3 cartes, une colonne sur mobile et deux sur grand écran.

export function GiftListInline() {
  const gifts = useGiftData(); // hook fictif, données venant de toolOutput

  return (
    <WidgetContainer>
      <h2 className="text-base font-semibold mb-3">
        Sélection de cadeaux
      </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">
          Affichage des 3 premiers éléments. Déployez le widget pour tout voir.
        </p>
      )}
    </WidgetContainer>
  );
}

Et la version fullscreen : grille, filtres, davantage de cartes.

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">
          Des cadeaux pour vous
        </h1>
        <input
          value={query}
          onChange={e => setQuery(e.target.value)}
          placeholder="Filtrer par nom"
          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>
  );
}

Ici, nous autorisons un défilement vertical interne du contenu fullscreen (overflow-y-auto sur main), ce qui est normal pour le mode plein écran. La version inline, comme le recommandent les guides, reste compacte et « lisible en 2 secondes ».

Schéma : comportement selon les modes

Pour ancrer les idées, dessinons un diagramme simple :

stateDiagram-v2
    [*] --> Inline
    Inline: 3 cartes, minimum de texte
    Inline --> Fullscreen: Clic « Déployer » / « Tout afficher »
    Fullscreen: Grille, filtres, beaucoup de données
    Fullscreen --> Inline: Bouton « Fermer » / action de l’hôte
    Fullscreen --> PiP: Opération longue, afficher la progression
    PiP: Petit panneau de statut
    PiP --> Inline: Opération terminée, afficher le message final

Ce scénario ressemble beaucoup aux patterns UX décrits : inline comme teaser, fullscreen comme outil de travail, PiP comme indicateur de processus.

7. Pratique : deux modes pour un même widget

Passons à la pratique. Dans le cadre de cette leçon, il est pertinent de réaliser deux étapes dans l’application d’apprentissage actuelle.

Étape 1. Widget inline avec carte

Étendez le GiftGenius actuel pour qu’en mode inline, le widget :

  • affiche le titre « Sélection de cadeaux » ;
  • affiche jusqu’à trois cartes de cadeaux depuis toolOutput ;
  • affiche l’indication « Déployez le widget pour tout voir » s’il y a plus de trois cadeaux ;
  • ajuste proprement sa hauteur via useAutoResize et notifyIntrinsicHeight().

Les styles doivent reposer sur le thème : utilisez des classes ou variables liées à la theme, pas des couleurs figées.

Étape 2. Version fullscreen avec formulaire

Ajoutez ensuite une vue fullscreen qui :

  • affiche un en‑tête + une recherche par nom ;
  • affiche tous les cadeaux dans une grille ;
  • autorise le défilement vertical dans la zone principale ;
  • fournit un bouton « Revenir à la conversation » (qui appelle requestDisplayMode('inline')).

La composition peut ressembler à ceci :

export function GiftGeniusWidget() {
  const { displayMode } = useDisplayMode();

  return (
    <>
      <DisplayModeDebug />
      {displayMode === 'fullscreen' ? (
        <GiftListFullscreen />
      ) : (
        <GiftListInline />
      )}
    </>
  );
}

Dans ChatGPT Dev Mode, vous pourrez basculer manuellement le mode ou demander le fullscreen de manière programmatique en cliquant sur le bouton « Tout afficher » dans la version inline (via useRequestDisplayMode). Cet exercice consolide la compréhension de la façon dont une même App peut apparaître et se comporter différemment selon le displayMode.

8. Erreurs typiques lors de la gestion de l’apparence du widget

Avant d’avancer dans le cours, fixons quelques pièges classiques liés à displayMode, aux tailles, au thème et au layout. En les évitant dès le début, la vie avec l’Apps SDK sera bien plus agréable.

Erreur n° 1 : ignorer displayMode et tenter de tout rendre « façon fullscreen » de force.
Parfois, les développeurs conçoivent un layout lourd (presque comme un SPA autonome) qui rentre tout juste en inline. L’utilisateur se retrouve avec un Notion miniature, des scrollbars et des milliers d’éléments. L’approche correcte consiste à concevoir des vues différentes pour des modes différents et à respecter le fait que inline est un format compact « un écran ».

Erreur n° 2 : hauteur fixe énorme et double défilement.
Mettre height: 800px et oublier maxHeight mène à un widget coupé ou à un défilement interne et externe simultanément. L’utilisateur devra « trouver » la bonne scrollbar, ce qui nuit fortement à l’UX. À la place, lisez maxHeight, limitez via max-height et, lors des changements de hauteur, signalez‑les via notifyIntrinsicHeight().

Erreur n° 3 : ignorer le thème et tenter de « recolorer tout selon la marque ».
Si vous imposez vos polices, fonds, dégradés contrastés et ignorez totalement le thème clair/sombre de ChatGPT, vous cassez l’unité visuelle de la plateforme. Les guidelines disent clairement : utiliser les couleurs et polices système, et apporter la marque par des accents mesurés (bouton, icône, logo). Suivez la theme via un hook et adaptez la palette.

Erreur n° 4 : UI trop complexe en PiP/modales.
Essayer de caser un formulaire entier avec de nombreux champs dans une petite fenêtre PiP n’est pas viable. Ces fenêtres conviennent uniquement à des cas très simples : progression d’un processus, un ou deux boutons, un champ de saisie. Tout le reste est candidat au fullscreen.

Erreur n° 5 : maquetter en dur pour 800 px et ne pas tester sur mobile.
Maquetter en dur pour 800 px en supposant que « ça passera sur téléphone ». En réalité, le client mobile de ChatGPT a une largeur et un comportement différents, et PiP est encore plus étroit. N’oubliez pas userAgent/safeArea, utilisez grid/flex sans largeur fixe et testez au moins une fois votre widget avec un layout étroit.

Erreur n° 6 : travailler directement avec window.openai sans hooks.
En théorie, vous pouvez écrire const mode = window.openai.displayMode, mais vous devrez alors gérer vous‑même les abonnements aux événements, réfléchir aux mises à jour React et subir des bugs si l’SDK change quelque chose. Les hooks (useDisplayMode, useMaxHeight, useOpenAiGlobal, useRequestDisplayMode) sont là pour cacher cette routine et garder un code plus propre. Mieux vaut les utiliser et dormir tranquille.

Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION