JavaRush /Курси /ChatGPT Apps /Керування зовнішнім виглядом:

Керування зовнішнім виглядом: displayMode, maxHeight, borders, theme, layout

ChatGPT Apps
Рівень 3 , Лекція 1
Відкрита

1. Навіщо взагалі керувати зовнішнім виглядом

Зараз ваш віджет, найімовірніше, виглядає як «звичайний React‑компонент»: якийсь div, список елементів, пара кнопок. У звичайному вебі цього часто достатньо. Але в ChatGPT є нюанс: ваш UI існує всередині чату, де користувач уже має багато візуального контексту — повідомлення, інші застосунки, голосовий інтерфейс, а також обмеження за розміром контейнера.

Важливо памʼятати дві речі.

По‑перше, віджет має режим відображення (displayMode): inline, fullscreen, іноді PiP. Від режиму залежать доступна площа, поведінка прокрутки та очікування користувача.

По‑друге, платформа передає віджету обмеження за висотою (maxHeight) і тему (theme). Якщо ви їх ігноруєте та малюєте щось розміром із Notion усередині одного повідомлення, чат перетворюється на «чорну діру», де все тоне в одному величезному iframe. OpenAI прямо радить робити UI лаконічним і зважати на системні кольори та типографіку.

Типовий сценарій GiftGenius добре показує, як це працює на практиці. Користувач просить: «Підбери подарунок другові до $50». ChatGPT запускає GiftGenius, який у режимі inline показує компактні картки подарунків і кілька кнопок. Користувач натискає «Детальніше» — віджет запитує fullscreen і вже там показує фільтри, докладний опис і відгуки. А коли настає етап оформлення покупки, можна показати невеликий PiP або модальне вікно зі статусом «Обробляємо замовлення…», не перекриваючи весь чат.

Наша мета в цій лекції — навчитися:

  • розуміти, який зараз displayMode, і коректно на нього реагувати;
  • на запит перемикати режим (inline ↔ fullscreen, іноді PiP);
  • дотримуватися maxHeight і не влаштовувати «подвійну прокрутку»;
  • адаптувати стилі під світлу/темну тему та ширину екрана;
  • будувати layout, який виглядає «рідним» усередині ChatGPT.

2. Режими displayMode: inline, fullscreen, PiP

Почнемо з термінів. displayMode — це стан контейнера вашого віджета в ChatGPT. Він надходить від платформи (через window.openai.displayMode або хук useDisplayMode) і може мати значення на кшталт "inline", "fullscreen", "pip".

Inline

Inline — режим за замовчуванням. Віджет вставляється прямо в потік повідомлень як ще один «блок» між текстовими відповідями. Ширина обмежена шириною колонки чату (на десктопі ~700–800 px, на телефоні — ширина екрана), а висота динамічна, але не безмежна.

Inline ідеально підходить для:

  • коротких, самодостатніх подань: картки подарунків, список опцій, резюме пошуку;
  • однієї-двох дій: «Вибрати», «Скасувати», «Показати ще».

Для GiftGenius це основний режим: користувач написав запит, а ви показуєте 3–5 карток подарунків із кнопками й не займаєте весь екран.

Fullscreen (Canvas)

Fullscreen (або canvas) — це режим, коли ваш віджет займає більшу частину видимої області. Чат при цьому не зникає: рядок введення все ще доступний, але основна увага — на вашому UI.

Вмикати fullscreen має сенс, коли:

  • багато полів введення або складний майстер (оформлення замовлення, складні фільтри, налаштування);
  • потрібно показати великі таблиці, карти, порівняння десятків елементів;
  • inline уже не вміщується і починає виглядати як міні‑Excel заввишки 700 px.

У GiftGenius fullscreen потрібен, щоб дати користувачу повноцінні фільтри, сортування, докладні описи, можливо — кілька вкладок.

PiP / Modal

PiP (picture-in-picture) і модальні вікна — це невеликі «плаваючі» вікна поверх основного контенту. У поточних реалізаціях Apps SDK PiP часто зроблено або як особливий режим displayMode, або як модальне вікно через requestModal().

Вони корисні, коли:

  • потрібно показати статус тривалого процесу (обробка замовлення, рендеринг відео);
  • потрібно уточнити щось невелике, не перериваючи основний потік (швидке підтвердження);
  • ви хочете дати користувачу змогу «тримати віджет на видноті», продовжуючи чат.

У GiftGenius це може бути невелика панель «Оформлюємо замовлення… 30 %» із кнопкою «Скасувати».

Невелике порівняння

Таблиця для зручності:

Режим Де живе Типові кейси Обмеження
inline
у потоці повідомлень Списки, картки, одна-дві кнопки Обмежена висота, вузька ширина
fullscreen
над чатом / збоку Майстри, складні форми, таблиці Потребує продуманого layout і навігації
PiP / modal плаваючий шар Статус, мініформи, відео Дуже мало місця: усе має бути великим і простим

Важливо не ставитися до fullscreen як до «справжнього застосунку», а до inline — як до «попереднього перегляду». Це той самий застосунок, просто в різних «позах».

3. Хуки для роботи з режимом: useDisplayMode, useRequestDisplayMode, useRequestModal

Тепер, коли ми розібралися, що таке inline/fullscreen/PiP з погляду UX, подивімося, як працювати з ними в коді через хуки Apps SDK.

Замість того щоб читати window.openai.displayMode напряму, ми використовуємо хук із шаблону. Він підписаний на зміни й уберігає вас від «ритуальних танців» із подіями SDK. Типовий інтерфейс такий:

// Псевдотипи; реальні назви звіряйте з шаблоном
type DisplayMode = 'inline' | 'fullscreen' | 'pip';

function useDisplayMode() {
  // повертає поточний режим
  return { displayMode: 'inline' as DisplayMode };
}

function useRequestDisplayMode() {
  // функція-запит на зміну режиму
  return {
    requestDisplayMode: (mode: DisplayMode) => {
      /* викликає window.openai.requestDisplayMode */
    },
  };
}

Зробімо простий компонент, який показує поточний режим і дає кнопку «Розгорнути / Згорнути»:

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>Режим: {displayMode}</span>
      <button onClick={toggle} className="underline">
        Перемкнути
      </button>
    </div>
  );
}

У реальних застосунках подібні «відладочні» елементи зазвичай ховають, але в режимі розробника (Dev Mode) такий компонент чудово допомагає відчути, як віджет поводиться під час перемикання.

Inline vs Fullscreen різними підкомпонентами

Поширена помилка — намагатися одним і тим самим компонуванням обслуговувати всі режими й закидати в JSX купу if (displayMode === ...). Набагато зручніше розділити подання:

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

Так код читається як «якщо fullscreen — ось складний майстер, інакше — компактний inline». І кожен підкомпонент можна стилізувати окремо, з огляду на власні обмеження. Саме такий підхід і радять у модулі: розділяти режими на окремі підкомпоненти замість величезного if/else в одному компоненті.

Модалки: useRequestModal

Якщо шаблон дає хук useRequestModal, його інтерфейс зазвичай такий:

const { requestModal } = useRequestModal();
// requestModal({ title }) або щось у цьому дусі.

Модальні вікна чимось схожі на fullscreen, але не замінюють його: fullscreen — для великих сценаріїв, а модалка — для одного короткого кроку (підтвердити дію, ввести код купона тощо).

4. Контроль розмірів: maxHeight, прокрутка і notifyIntrinsicHeight()

Друга важлива вісь — висота. Платформа повідомляє віджету: «Ось максимально доступна висота». Цей ліміт можна прочитати в window.openai.maxHeight або через хук useMaxHeight.

Чому не можна просто зробити «height: 5000px»

Якщо ви ігноруєте maxHeight і задаєте величезну фіксовану висоту, ChatGPT буде змушений обрізати ваш контент. Або ж користувач отримає подвійну прокрутку: зовнішню — у чаті, і внутрішню — у вашому віджеті. Це поганий UX: користувачу доводиться вгадувати, де саме треба прокручувати, щоб дістатися потрібної кнопки.

Правильна стратегія така:

  1. Читати ліміт maxHeight.
  2. Будувати layout так, щоб основна прокрутка залишалася в чаті (особливо в inline).
  3. У fullscreen можна дозволити собі трохи внутрішньої прокрутки, але обережно.

useMaxHeight і обмеження контейнера

Напишімо просту обгортку, яка встановлює максимум за висотою для кореневого контейнера:

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

export function WidgetContainer(props: { children: React.ReactNode }) {
  const { maxHeight } = useMaxHeight(); // наприклад, 600

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

Тут ми чесно обмежуємо висоту й вмикаємо вертикальну прокрутку усередині контейнера — але в розумних межах. На практиці в inline краще уникати великої внутрішньої прокрутки. Замість величезних списків показуйте частину даних із кнопкою «Показати ще» або пропонуйте fullscreen.

Динамічна висота і notifyIntrinsicHeight()

Ще один нюанс: ваш контент може змінювати розмір із часом. Наприклад, спочатку ви показуєте спінер «Завантажуємо подарунки…», потім — список із 10 карток, потім користувач згортає/розгортає фільтри. Щоб ChatGPT правильно виділяв місце під віджет і не обрізав його, про зміну висоти потрібно повідомити хостові нове значення. Для цього є notifyIntrinsicHeight().

У шаблоні це часто обгорнуто в хук на кшталт useAutoResize. Його можна реалізувати приблизно так:

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

І використовуємо:

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

  return (
    <div ref={containerRef}>
      {/* ваш вміст */}
    </div>
  );
}

Ідея проста: коли ваш кореневий div змінює висоту, ви викликаєте API SDK, і ChatGPT підлаштовує контейнер. Такий підхід прямо рекомендують досвідчені розробники: «обгортка з автопідлаштуванням розміру» навколо всього вмісту.

Невелика схема

Уявімо це у вигляді блок-схеми:

flowchart TD
    A[Вміст віджета змінився] --> B[ResizeObserver фіксує нову висоту]
    B --> C["Виклик notifyIntrinsicHeight(newHeight)"]
    C --> D[ChatGPT збільшує/зменшує контейнер]
    D --> E[Користувач бачить акуратний скрол без обрізання]

Із розмірами та висотою розібралися: віджет не повинен виходити за відведений простір і влаштовувати користувачу квест із подвійною прокруткою.

5. Тема (theme), кольори та рамки: як зробити віджет «рідним»

Якщо displayMode і maxHeight визначають, скільки місця ми маємо, то тема (theme) і палітра відповідають за те, як цей фрагмент інтерфейсу виглядає всередині чату.

ChatGPT підтримує щонайменше світлу й темну теми. Платформа передає це у ваш віджет через window.openai.theme та/або в _meta["openai/theme"], а в React‑шаблоні є хук useOpenAiGlobal("theme") або щось на кшталт useTheme.

Головна думка: ваш UI має підлаштовуватися під тему, а не навʼязувати свою.

Отримання теми

Приклад простого хука:

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

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

У компоненті:

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

У реальному проєкті ви, найімовірніше, використовуєте Tailwind із darkMode: 'class' і вішаєте клас dark на кореневий контейнер віджета. Але суті це не змінює: тема приходить із Apps SDK, а не «живе» сама по собі.

Кольори, рамки та типографіка

За рекомендаціями OpenAI:

  • використовуйте системні шрифти та акуратну типографіку;
  • не перевизначайте системні кольори надто агресивно;
  • віджет має бути «рідним» елементом чату, а не окремим лендингом із кислотним градієнтом.

Хороший підхід для контейнера 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">
        Вибрати
      </button>
    </div>
  );
}

Тут передбачається, що bg-background, border-border, text-foreground, bg-primary тощо — це CSS‑змінні/utility‑класи, повʼязані з темою ChatGPT. Такий підхід описують і в рекомендаціях: використовуйте змінні й класи, привʼязані до теми, а не задавайте кольори жорстко.

6. Layout і адаптивність: desktop, mobile, PiP

Третя вісь — ширина й пристрій. Якщо дуже спростити, зовнішній вигляд віджета визначається режимом (displayMode), доступною висотою (maxHeight) і доступною шириною (десктоп/мобільний/PiP).

У цьому розділі розберемося з третім параметром. На десктопі inline‑віджет має одну ширину, на мобільному — іншу; у PiP взагалі місця майже немає. Apps SDK передає сигнали на кшталт userAgent, safeArea, іноді — розмір контейнера, які можна читати через useOpenAiGlobal.

Загальні принципи

Нижче — кілька важливих принципів.

По‑перше, не розраховуйте на фіксовану ширину. Екран користувача може бути вузьким (телефон) або широким (великий десктоп). Тому layout краще будувати на flex/grid з auto-fit, ніж на жорсткому width: 400px.

По‑друге, уникайте горизонтальної прокрутки. Якщо ваша таблиця або картки не вміщуються, краще перейти у fullscreen або показати скорочену версію. Хоча інколи доречною може бути карусель зі слайдами.

По‑третє, памʼятайте, що PiP/модальні вікна часто дуже вузькі, і туди не варто поміщати велику форму — користувачу буде фізично складно влучати в поля.

Ці моменти прямо підкреслюють у документації: адаптивність, safeArea, різниця між десктопом і мобільним та небезпека перевантаженого компонування.

Різні варіанти компонування для inline і fullscreen

Повернімося до GiftGenius. Список подарунків в inline і fullscreen може виглядати дуже по‑різному. Зробімо два компоненти.

Компактний inline: максимум 3 картки, одна колонка на мобільному й дві — на широкому екрані.

export function GiftListInline() {
  const gifts = useGiftData(); // умовний хук, беремо з toolOutput

  return (
    <WidgetContainer>
      <h2 className="text-base font-semibold mb-3">
        Підбірка подарунків
      </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">
          Показано перші 3 варіанти. Розгорніть віджет, щоб побачити все.
        </p>
      )}
    </WidgetContainer>
  );
}

І fullscreen‑версія: сітка, фільтри, більше карток.

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">
          Подарунки для вас
        </h1>
        <input
          value={query}
          onChange={e => setQuery(e.target.value)}
          placeholder="Фільтр за назвою"
          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>
  );
}

Тут ми допускаємо внутрішню вертикальну прокрутку fullscreen‑контенту (overflow-y-auto на main), що нормально для повноекранного режиму. Inline‑версія, як і радять рекомендації, залишається компактною і легко «зчитується за 2 с».

Схема: поведінка за режимами

Для закріплення намалюймо просту діаграму:

stateDiagram-v2
    [*] --> Inline
    Inline: 3 картки, мінімум тексту
    Inline --> Fullscreen: Клік "Розгорнути" / "Показати все"
    Fullscreen: Сітка, фільтри, багато даних
    Fullscreen --> Inline: Кнопка "Закрити" / дія хоста
    Fullscreen --> PiP: Тривала операція, показати прогрес
    PiP: Невелика панель статусу
    PiP --> Inline: Операцію завершено, показуємо підсумкове повідомлення

Такий сценарій дуже схожий на описані UX‑підходи: inline як тизер, fullscreen як робочий інструмент, PiP як індикатор процесу.

7. Практика: два режими одного й того ж віджета

Пора закріпити це в коді. Як практику в межах цієї лекції варто зробити два кроки в поточному навчальному застосунку.

Крок 1. Inline‑віджет із карткою

Розширте поточний GiftGenius так, щоб у режимі inline віджет:

  • показував заголовок «Підбірка подарунків»;
  • відображав до трьох карток подарунків із toolOutput;
  • показував підказку «Розгорніть віджет, щоб побачити все», якщо подарунків більше трьох;
  • акуратно підлаштовував висоту через useAutoResize і notifyIntrinsicHeight().

При цьому стилі мають спиратися на тему: використовуйте класи або змінні, завʼязані на theme, а не жорсткі кольори.

Крок 2. Fullscreen‑версія з формою

Потім додайте fullscreen‑подання, яке:

  • показує заголовок + пошук за назвою;
  • виводить усі подарунки в сітці;
  • дозволяє вертикальну прокрутку всередині основної області;
  • надає кнопку «Повернутися до діалогу» (яка викликає requestDisplayMode('inline')).

Композиція може виглядати так:

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

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

У ChatGPT Dev Mode ви зможете вручну перемикати режим або запросити fullscreen програмно — наприклад, після натискання кнопки «Показати все» в inline‑версії (через useRequestDisplayMode). Ця вправа закріпить розуміння того, як один і той самий застосунок може виглядати й поводитися по‑різному залежно від displayMode.

8. Типові помилки під час керування зовнішнім виглядом віджета

Перш ніж рухатися далі за курсом, зафіксуймо кілька типових «граблів», повʼязаних із displayMode, розмірами, темою і layout. Якщо уникати їх із самого початку, працювати з Apps SDK буде значно приємніше.

Помилка № 1: Ігнорування displayMode і спроба «насильно» зробити все схожим на fullscreen.
Інколи розробники малюють один важкий інтерфейс (майже як окремий SPA), який ледве вміщується в inline. У результаті користувач бачить мініатюрний Notion із прокрутками й мільйоном елементів. Коректний підхід — проєктувати різні подання для різних режимів і зважати на те, що inline — це компактний, «одноекранний» формат.

Помилка № 2: Величезна фіксована висота і подвійна прокрутка.
Поставити height: 800px і забути про maxHeight — простий шлях до того, що ваш віджет буде або обрізаний, або породить внутрішню й зовнішню прокрутку одночасно. Користувач почне «ловити» правильну смужку прокрутки — і це відчутно псує UX. Натомість потрібно читати maxHeight, обмежувати за max-height і при зміні висоти повідомляти про це через notifyIntrinsicHeight().

Помилка № 3: Ігнорування теми й спроба «перефарбувати все під бренд».
Якщо ви задаєте власні шрифти, фони, контрастні градієнти й повністю ігноруєте світлу/темну тему ChatGPT, ви ламаєте візуальну єдність платформи. Рекомендації прямо кажуть: використовуйте системні кольори та шрифти, а бренд додавайте акуратними акцентами (кнопка, іконка, логотип). Стежте за theme через хук і підлаштовуйте палітру.

Помилка № 4: Надто складний UI у PiP/модальних вікнах.
Намагатися вмістити цілу форму з багатьма полями в маленьке PiP‑вікно — шлях у нікуди. Там доречні лише дуже прості випадки: прогрес процесу, одна-дві кнопки, одне поле введення. Усе інше — кандидати на fullscreen.

Помилка № 5: Жорстко верстати під 800 px і не тестувати на мобільних.
Жорстко верстати під 800 px і вважати, що «якось на телефоні вже влізе», — погана ідея. Насправді мобільний клієнт ChatGPT має зовсім іншу ширину й поведінку, а PiP ще вужчий. Не забувайте про userAgent/safeArea, використовуйте grid/flex без жорсткої ширини й хоча б раз подивіться на свій віджет у вузькому layout.

Помилка № 6: Робота напряму з window.openai без хуків.
Формально ви можете написати const mode = window.openai.displayMode, але тоді самі будете підписуватися на події, думати про оновлення React і ловити баги, якщо SDK щось змінить. Хуки (useDisplayMode, useMaxHeight, useOpenAiGlobal, useRequestDisplayMode) створені спеціально, щоб сховати цю рутину й тримати код чистішим. Краще використовувати їх — і жити спокійніше.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ