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 %» із кнопкою «Скасувати».
Невелике порівняння
Таблиця для зручності:
| Режим | Де живе | Типові кейси | Обмеження |
|---|---|---|---|
|
у потоці повідомлень | Списки, картки, одна-дві кнопки | Обмежена висота, вузька ширина |
|
над чатом / збоку | Майстри, складні форми, таблиці | Потребує продуманого 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: користувачу доводиться вгадувати, де саме треба прокручувати, щоб дістатися потрібної кнопки.
Правильна стратегія така:
- Читати ліміт maxHeight.
- Будувати layout так, щоб основна прокрутка залишалася в чаті (особливо в inline).
- У 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) створені спеціально, щоб сховати цю рутину й тримати код чистішим. Краще використовувати їх — і жити спокійніше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ