1. Зачем вообще управлять внешним видом
Сейчас ваш виджет, скорее всего, выглядит как «нормальный React-компонент»: какой-то div, список элементов, пара кнопок. В обычном вебе этого часто достаточно. В ChatGPT же есть нюанс: ваш UI живёт внутри чата, где у пользователя уже много визуального контекста — сообщения, другие Apps, голосовой интерфейс, плюс ограничения по размеру контейнера.
Важно помнить две вещи.
Во‑первых, у виджета есть режим отображения (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–800px, на телефоне — ширина экрана), а высота динамическая, но не бесконечная.
Inline идеально подходит для:
- коротких, само-достаточных представлений: карточки подарков, список опций, резюме поиска;
- одного-двух действий: «Выбрать», «Отменить», «Показать ещё».
Для GiftGenius это основной режим: пользователь написал запрос, а вы показываете 3–5 карточек подарков с кнопками и не захватываете весь экран.
Fullscreen (Canvas)
Fullscreen (или canvas) — это режим, когда ваш виджет занимает большую часть видимой области. Чат при этом не исчезает: строка ввода всё ещё доступна, но основное внимание — на вашем UI.
Включать fullscreen имеет смысл, когда:
- много полей ввода или сложный мастер (оформление заказа, сложные фильтры, настройки);
- нужно показать большие таблицы, карты, сравнение десятков элементов;
- inline уже не помещается и начинает выглядеть как мини‑Excel высотой в 700px.
В GiftGenius fullscreen нужен, чтобы дать пользователю полноценные фильтры, сортировку, подробные описания, возможно, несколько вкладок.
PiP / Modal
PiP (picture-in-picture) и модалки — это небольшие «плавающие» окна поверх основного контента. В текущих реализациях Apps SDK PiP часто реализуется либо как особый режим displayMode, либо как модальное окно через requestModal().
Они полезны, когда:
- нужно показать статус длительного процесса (обработка заказа, рендеринг видео);
- нужно спросить что-то небольшое, не прерывая основной поток (быстрое подтверждение);
- вы хотите дать пользователю возможность «держать виджет на виду», продолжая чат.
В GiftGenius это может быть маленькая панелька «Оформляем заказ… 30%» с кнопкой «Отменить».
Небольшое сравнение
Таблица для визуального восприятия:
| Режим | Где живёт | Типичные кейсы | Ограничения |
|---|---|---|---|
|
в потоке сообщений | Списки, карточки, одна-две кнопки | Ограниченная высота, узкая ширина |
|
над чатом / сбоку | Мастера, сложные формы, таблицы | Требует осмысленного layout и навигации |
| PiP / modal | плавающий слой | Статус, мини‑формы, видео | Очень мало места, всё должно быть крупным и простым |
Важно не относиться к fullscreen как к «настоящему приложению», а к inline — как к «preview». Это один и тот же App, просто в разных «позах».
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>
);
}
В реальных App вы подобные «отладочные» элементы обычно прячете, но в Dev Mode такой компонент отлично помогает почувствовать, как виджет себя ведёт при переключении.
Inline vs Fullscreen разными подкомпонентами
Частая ошибка — пытаться одним и тем же layout’ом обслужить все режимы и закидывать в 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) и доступной шириной (desktop/mobile/PiP).
В этом разделе разберёмся с третьим параметром. На десктопе inline-виджет имеет одну ширину, на мобильном — другую; в PiP вообще места чуть-чуть. Apps SDK передаёт сигналы вроде userAgent, safeArea, иногда размер контейнера, которые можно читать через useOpenAiGlobal.
Общие принципы
Несколько важных принципов ниже.
Во-первых, не рассчитывайте на фиксированную ширину. Экран пользователя может быть узким (телефон) или широким (большой десктоп). Поэтому layout лучше строить на flex/grid с auto-fit, чем на жёстком width: 400px.
Во-вторых, избегайте горизонтального скролла. Если ваша таблица или карточки не влезают, лучше перейти в fullscreen или показать укороченную версию. Хотя можно использовать карусель со слайдами.
В-третьих, учитывайте, что PiP/модалки часто очень узкие, и туда нельзя помещать большую форму — пользователю будет физически больно попадать в поля.
Эти моменты прямо подчёркиваются в документации: адаптивность, safeArea, разница desktop vs mobile и опасность перегруженных layout’ов.
Разные layout’ы для 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 секунды».
Schematic: поведение по режимам
Для закрепления нарисуем простую диаграмму:
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). Это упражнение закрепит понимание того, как один и тот же App может выглядеть и вести себя по‑разному в зависимости от displayMode.
8. Типичные ошибки при управлении внешним видом виджета
Перед тем как идти дальше по курсу, давайте зафиксируем несколько типичных граблей, связанных с displayMode, размерами, темой и layout’ом. Если их избегать с самого начала, жизнь с Apps SDK будет сильно приятнее.
Ошибка №1: Игнорирование displayMode и попытка «насильно» сделать всё fullscreen‑подобным.
Иногда разработчики рисуют один тяжёлый layout (почти как отдельный SPA), который еле-еле влезает в inline. В результате пользователь видит миниатюрный Notion со скроллами и миллионом элементов. Корректный подход — проектировать разные представления под разные режимы и уважать, что inline — это компактный, «одноэкранный» формат.
Ошибка №2: Огромная фиксированная высота и двойной скролл.
Поставить height: 800px и забыть про maxHeight — простой путь к тому, что ваш виджет будет либо обрезан, либо породит внутренний и внешний скролл одновременно. Пользователь начнёт «ловить» правильную полоску прокрутки, что сильно портит UX. Вместо этого нужно читать maxHeight, ограничивать по max-height и при изменениях высоты сообщать об этом через notifyIntrinsicHeight().
Ошибка №3: Игнорирование темы и попытка «перекрасить всё под бренд».
Если вы ставите свои шрифты, фоны, контрастные градиенты и полностью игнорируете светлую/тёмную тему ChatGPT, вы ломаете визуальное единство платформы. Гайды явно говорят: использовать системные цвета и шрифты, а бренд приносить аккуратными акцентами (кнопка, иконка, логотип). Следите за theme через хук и подстраивайте палитру.
Ошибка №4: Слишком сложный UI в PiP/модалках.
Пытаться засунуть целую форму с множеством полей в маленькое PiP-окно — путь в никуда. Там уместны только очень простые случаи: прогресс процесса, одна-две кнопки, одно поле ввода. Всё остальное — кандидаты на fullscreen.
Ошибка №5: Жёстко верстать под 800px и не тестировать на мобильных.
Жёстко верстать под 800px и считать, что «ну как‑нибудь на телефоне уж влезет». В реальности mobile‑клиент ChatGPT имеет совсем другую ширину и поведение, а PiP ещё уже. Не забывайте про userAgent/safeArea, используйте grid/flex без жёсткой ширины и хотя бы раз посмотрите на свой виджет с узким layout’ом.
Ошибка №6: Работа напрямую с window.openai без хуков.
Формально вы можете написать const mode = window.openai.displayMode, но тогда сами будете подписываться на события, думать про обновления React и ловить баги, если SDK что‑то поменяет. Хуки (useDisplayMode, useMaxHeight, useOpenAiGlobal, useRequestDisplayMode) сделаны специально, чтобы спрятать эту рутину и держать код чище. Лучше использовать их и жить спокойно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ