2. Зачем вообще нужен fullscreen, если есть inline?
В предыдущей лекции про inline мы уже договорились: если задача короткая и укладывается в 5–7 объектов или один экран, inline-карточка — идеальный вариант. Список из нескольких подарков, пара фильтров, одна-две кнопки — всё это отлично живёт прямо в потоке сообщений.
Но у любого приложения наступает момент, когда «ещё одна карточка» уже не спасает:
- нужно собрать много параметров (профиль получателя, ограничения по доставке, способы оплаты);
- нужен мастер из нескольких шагов;
- есть большие таблицы, графики, карты, длинные описания.
Inline здесь начинает нервно дёргаться: ширина ограничена колонкой чата, высота — тоже, навигации нет, да и скролл у чата один. Именно для таких сценариев в Apps SDK есть fullscreen‑режим — «погружённый» интерфейс, в котором ваш виджет занимает большую часть экрана и может показывать сложный layout.
Второй герой сегодняшнего дня — PiP, маленькое плавающее окно, которое живёт поверх чата. Его типичные роли: статус фоновой задачи, мини‑плеер, таймер, индикатор прогресса. PiP идеален, когда что-то длительное идёт «на фоне», а пользователь продолжает разговаривать с GPT.
Важно помнить: и fullscreen, и PiP — не замена inline, а надстройка. Начинаем с inline, а в fullscreen переходим, когда inline становится тесно; в PiP уходим, когда всё интересное уже запущено и нужно просто «держать под глазом» статус.
3. Технический фундамент: displayMode и переключения режимов
С точки зрения Apps SDK у вашего виджета есть текущее состояние отображения — displayMode. На момент написания курса есть три основных режима: "inline", "fullscreen" и "pip" (picture-in-picture).
Хост (ChatGPT) сообщает вашему виджету текущий режим через глобальные данные в window.openai и специальные хуки из SDK. В типичном React-шаблоне есть что-то вроде:
// псевдоним из Apps SDK-шаблона
const mode = useDisplayMode(); // 'inline' | 'fullscreen' | 'pip'
if (mode === "fullscreen") {
// рендерим наш мастер
} else {
// рендерим компактный inline UI
}
SDK также даёт метод window.openai.requestDisplayMode({ mode }) и/или хук useRequestDisplayMode, чтобы попросить хост переключить режим. Этот метод возвращает промис с фактически установленным режимом, потому что платформа может отказать или скорректировать ваш запрос (например, PiP на мобильном почти всегда превращается в fullscreen).
Схематично жизненный цикл режимов можно представить так:
stateDiagram-v2
[*] --> Inline
Inline --> Fullscreen: requestDisplayMode('fullscreen')
Fullscreen --> Inline: requestDisplayMode('inline') / кнопка "Назад"
Fullscreen --> PiP: requestDisplayMode('pip')
PiP --> Fullscreen: "Развернуть"
PiP --> Inline: завершение задачи
Реальные названия и точный набор режимов могут меняться с версиями SDK, поэтому в проде всегда стоит перепроверять документацию, а не полагаться на «как было в курсе».
4. Первое переключение: делаем кнопку «Развернуть в полноэкранный режим»
Начнём с малого: возьмём наш уже существующий inline‑виджет GiftGenius — учебный App из прошлых модулей, который сейчас показывает 3–5 карточек подарков, — и добавим в него кнопку «Открыть подробный подбор» для перехода в fullscreen.
Предположим, что у нас в шаблоне есть два хука:
import { useDisplayMode, useRequestDisplayMode } from "@/sdk/display";
export const GiftGeniusWidget: React.FC = () => {
const mode = useDisplayMode();
const requestDisplayMode = useRequestDisplayMode();
if (mode === "fullscreen") {
return <GiftFullscreenWizard />;
}
return (
<InlineGiftPreview
onExpand={async () => {
await requestDisplayMode({ mode: "fullscreen" });
}}
/>
);
};
Здесь InlineGiftPreview — это наш текущий inline‑UI, а GiftFullscreenWizard — новый компонент‑мастер, который мы сейчас спроектируем. В обработчике onExpand мы не просто вызываем requestDisplayMode, но и ждём промис — так мы сможем позже реагировать на отказ (например, показать сообщение, если по какой-то причине fullscreen недоступен).
Сам InlineGiftPreview достаточно прост:
type InlineGiftPreviewProps = {
onExpand: () => void;
};
const InlineGiftPreview: React.FC<InlineGiftPreviewProps> = ({ onExpand }) => {
return (
<div>
<h3>Подбор подарков</h3>
{/* ...карточки подарков... */}
<button onClick={onExpand}>Открыть подробный подбор</button>
</div>
);
};
Пока что всё очень похоже на «открыть модалку», но разница в том, что контролирует это не ваш React, а хост‑приложение ChatGPT, и оно может показывать заголовок, системные кнопки «Назад» и т.п.
5. Проектируем fullscreen‑мастер GiftGenius
Теперь спроектируем fullscreen‑мастер подбора подарка. С UX‑точки зрения разумно разбить процесс на несколько логических шагов. Например:
- Кто получатель подарка и какой повод.
- Бюджет и тип подарков (физические, впечатления, цифровые).
- Проверка и подтверждение выбора.
В коде это можно отразить простой машиной состояний по шагам:
type WizardStep = "recipient" | "preferences" | "review";
type WizardState = {
step: WizardStep;
recipient?: { ageRange: string; relation: string };
preferences?: { budget: number; categories: string[] };
};
Создадим компонент GiftFullscreenWizard, который хранит это состояние в React и рендерит нужный экран.
const GiftFullscreenWizard: React.FC = () => {
const [state, setState] = useState<WizardState>({ step: "recipient" });
const goNext = (partial: Partial<WizardState>) => {
setState((prev) => ({ ...prev, ...partial }));
};
if (state.step === "recipient") {
return <RecipientStep state={state} onNext={goNext} />;
}
if (state.step === "preferences") {
return <PreferencesStep state={state} onNext={goNext} />;
}
return <ReviewStep state={state} />;
};
Каждый шаг — это маленький компонент с формой. Например, первый шаг:
type StepProps = {
state: WizardState;
onNext: (partial: Partial<WizardState>) => void;
};
const RecipientStep: React.FC<StepProps> = ({ state, onNext }) => {
const [relation, setRelation] = useState(state.recipient?.relation ?? "");
const [ageRange, setAgeRange] = useState(state.recipient?.ageRange ?? "");
return (
<div>
<h2>Кому выбираем подарок?</h2>
<input
placeholder="Кто это для вас?"
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
<input
placeholder="Возраст (например, 25–34)"
value={ageRange}
onChange={(e) => setAgeRange(e.target.value)}
/>
<button
onClick={() =>
onNext({
recipient: { relation, ageRange },
step: "preferences",
})
}
>
Далее
</button>
</div>
);
};
Во втором шаге мы собираем бюджет и категории, в третьем — вызываем callTool / MCP‑инструмент, который уже умеет подбирать подарки по этим параметрам, и показываем результаты.
Важно, что на fullscreen‑экране у нас есть место для:
- прогресс-бара или stepper’а;
- более развёрнутых полей и подсказок;
- состояний ошибки («что-то пошло не так, попробуйте ещё раз»).
Рекомендация из UX‑гайдлайнов: каждый шаг должен оставаться максимально простым, без перегруза полями; лучше 3–4 ясных шага, чем один монстр-формуляр.
6. UX fullscreen‑мастера: прогресс, ошибки, возврат
Просто вывести форму на весь экран — это половина дела. Пользователю нужно:
- понимать, на каком он шаге;
- иметь возможность вернуться назад;
- видеть, что происходит во время долгих операций.
Простейший stepper можно реализовать чисто визуально:
const Stepper: React.FC<{ step: WizardStep }> = ({ step }) => {
const index = step === "recipient" ? 1 : step === "preferences" ? 2 : 3;
return <p>Шаг {index} из 3</p>;
};
И просто вставить Stepper в каждый экран. Более продвинутый вариант — отрендерить горизонтальную «лестницу» шагов, но в рамках курса не будем устраивать школу верстальщика.
Важный момент — обработка ошибок. Допустим, на последнем шаге мы вызываем инструмент search_gifts:
const ReviewStep: React.FC<StepProps> = ({ state }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleConfirm = async () => {
setLoading(true);
setError(null);
try {
await callTool("search_gifts", {
recipient: state.recipient,
preferences: state.preferences,
});
// Результаты потом появятся в чате / виджете
} catch (e) {
setError("Не удалось подобрать подарки, попробуйте ещё раз.");
} finally {
setLoading(false);
}
};
return (
<div>
{/* показать сводку параметров */}
{error && <p style={{ color: "red" }}>{error}</p>}
<button disabled={loading} onClick={handleConfirm}>
{loading ? "Подбираем…" : "Подтвердить и подобрать"}
</button>
</div>
);
};
С точки зрения доступности нужно следить, чтобы:
- в fullscreen крупные кнопки «Далее», «Назад» и «Отмена» были легко кликабельны;
- текст имел адекватный контраст;
- по Tab можно было пройти все интерактивные элементы по порядку.
Если у вас есть возможность — стоит добавить aria-label для нестандартных контролов (например, кастомных переключателей категорий). Хотя курс не превращается в WCAG‑экзамен, базовое внимание к a11y поможет вам пройти ревью Store потом без лишней боли.
В итоге fullscreen‑мастер решает задачу сложных многошаговых сценариев: даёт место для форм, прогресса и ошибок. Но жизнь приложения на этом не заканчивается — многие задачи продолжаются «в фоне». Для этого у нас есть второй режим — PiP, о котором поговорим дальше.
7. Что такое PiP в мире ChatGPT и почему он «капризный»
Мы разобрались, как использовать fullscreen для сложных сценариев. Теперь давайте посмотрим на противоположный кейс — когда всё важное уже запущено и нужно лишь «держать под контролем» прогресс. Здесь в дело вступает PiP.
В веб‑мире «picture-in-picture» обычно ассоциируется с видео, которое висит в углу экрана поверх контента. В ChatGPT PiP — это маленькое плавающее окно виджета, которое остаётся на виду при скролле чата и может показывать статус, прогресс или компактный UI.
Несколько важных особенностей, которые нужно знать из документации и опыта early‑адоптеров:
- У PiP очень мало места. Это не площадка для форм и сложных layout’ов, а скорее для двух-трёх ключевых метрик и одной-двух кнопок.
- На десктопе PiP «прилипает» вверху и остаётся видимым при любом скролле; на мобильных же он часто автоматически превращается в fullscreen.
- Запрос requestDisplayMode с mode "pip" не гарантирует настоящего PiP. Платформа может вернуть другой режим (например, fullscreen) или вообще повести себя странно на старых версиях SDK, поэтому всегда проверяйте результат промиса и имейте fallback.
Из этого вытекает простой UX‑вывод: в PiP — только самое важное. Таймер, индикатор доставки, статус задачи, кнопка «Развернуть». Никаких 12 чекбоксов, таблиц на 10 колонок и «сделайте мне ещё кофе».
8. GiftGenius + PiP: долгий поиск и фоновый прогресс
Вернёмся к GiftGenius. Представим сценарий: пользователь прошёл fullscreen‑мастер, нажал «Подтвердить», и теперь ваш backend запускает довольно тяжёлый подбор — может быть, через MCP‑сервер вы дергаете несколько внешних API, пересчитываете цены, применяете кучу фильтров. Это может занять, скажем, 10–20 секунд.
С UX‑точки зрения не хочется 20 секунд держать пользователя в fullscreen с крутящимся спиннером. Лучше:
- Запустить подбор.
- Свернуть интерфейс в PiP, показывая прогресс.
- Дать пользователю возможность продолжать чат (например, задавать уточняющие вопросы).
- После завершения — вернуть результат inline или открыть новый fullscreen с подарками.
Сделаем простой хук, который будет управлять таким поведением:
const useLongGiftJob = () => {
const [status, setStatus] = useState<"idle" | "running" | "done">("idle");
const requestDisplayMode = useRequestDisplayMode();
const startJob = async (payload: any) => {
setStatus("running");
const resultMode = await requestDisplayMode({ mode: "pip" });
console.log("Фактический режим:", resultMode.mode);
await callTool("run_gift_job", payload);
setStatus("done");
await requestDisplayMode({ mode: "inline" });
};
return { status, startJob };
};
Теперь в ReviewStep вместо прямого callTool мы используем этот хук:
const ReviewStep: React.FC<StepProps> = ({ state }) => {
const { status, startJob } = useLongGiftJob();
return (
<div>
{/* ...сводка... */}
<button
disabled={status === "running"}
onClick={() => startJob(state)}
>
{status === "running" ? "Подбираем подарки…" : "Запустить подбор"}
</button>
</div>
);
};
Чтобы статус фоновой задачи был доступен и fullscreen‑мастеру, и PiP‑окну, в реальном коде есть смысл вынести useLongGiftJob в контекст и читать его через useLongGiftJobContext. Детали реализации контекста (Provider, createContext) опустим: важно, что job‑state живёт в одном месте, а разные UI‑слои просто на него подписываются.
И отдельный компонент для PiP‑отображения:
const GiftPipView: React.FC<{ status: string }> = ({ status }) => {
return (
<div>
<p>GiftGenius работает…</p>
<p>Статус: {status === "running" ? "в процессе" : "готово"}</p>
<button
onClick={() => window.openai.requestDisplayMode({ mode: "fullscreen" })}
>
Развернуть
</button>
</div>
);
};
В общем виджете мы подменим рендер так, чтобы учитывать и PiP:
const GiftGeniusWidget: React.FC = () => {
const mode = useDisplayMode();
const { status } = useLongGiftJobContext(); // через контекст, как обсуждали выше
if (mode === "pip") {
return <GiftPipView status={status} />;
}
if (mode === "fullscreen") {
return <GiftFullscreenWizard />;
}
return <InlineGiftPreview onExpand={/* как раньше */} />;
};
Такой сценарий прекрасно сочетается с голосовыми режимами (о них поговорим в лекции про voice): голосом мы запускаем подбор, PiP показывает прогресс, чат остаётся внизу и продолжает жить своей жизнью.
9. Видео + чат: когда fullscreen и PiP превращаются в медиаплеер
Исторически PiP чаще всего ассоциируется с видео, которое висит в углу экрана поверх контента. Поэтому логично отдельно разобрать сценарий «video + chat». Здесь тоже нет никакой магии: в большинстве случаев вы просто отображаете видео в fullscreen или PiP‑окне. Документация OpenAI прямо приводит медиасценарии как типичный пример использования fullscreen и PiP.
Что это может значить для GiftGenius? Например:
- вы показываете промо‑ролик подарка;
- короткий туториал «как красиво упаковать подарок»;
- видеообзор нескольких товаров.
В fullscreen можно отрендерить полноценный <video> с описанием и рекомендациями; в PiP — оставить только сам плеер и, возможно, маленький заголовок.
Простейший компонент‑обёртка:
const GiftVideoPlayer: React.FC<{ src: string; title: string }> = ({
src,
title,
}) => (
<div>
<h3>{title}</h3>
<video
src={src}
controls
style={{ width: "100%", borderRadius: 8 }}
/>
</div>
);
В fullscreen‑мастере мы можем предложить пользователю «Посмотреть видео‑обзор этого подарка», а потом свернуть его в PiP:
const WatchVideoStep: React.FC = () => {
const requestDisplayMode = useRequestDisplayMode();
return (
<div>
<GiftVideoPlayer src="/videos/gift-wrap.mp4" title="Как упаковать подарок" />
<button
onClick={() => requestDisplayMode({ mode: "pip" })}
>
Оставить видео в углу и вернуться к чату
</button>
</div>
);
};
Пара практических советов для медиасценариев:
- не включайте автоплей со звуком — это универсальный UX-антипаттерн;
- следите за субтитрами и возможностью ставить на паузу с клавиатуры (пробел, стрелки);
- в PiP‑окне не пытайтесь показывать весь сопутствующий текст, ограничьтесь самим видео.
10. Состояние, пересоздание виджета и мобильные особенности
Самый неприятный вопрос, который обычно задают на этом этапе: «А React‑состояние сохранится, если я переключусь из inline в fullscreen и обратно?»
Короткий ответ: не полагайтесь на это.
Технически поведение зависит от версии SDK и реализации хоста: в одних случаях переход между режимами происходит без пересоздания iframe, в других — виджет при этом размонтируется и монтируется заново. В документации отдельно подчёркивается, что сохранение контекста при смене режимов зависит от конкретной реализации SDK и её версии и не является гарантией для разработчика.
Практический подход:
- Всё критичное состояние (шаг мастера, введённые данные, идентификатор фоновой задачи) храните либо:
- в backend (через ваш MCP‑сервер и токены сессии),
- либо в ChatGPT-контексте (например, через tools, которые возвращают «текущее состояние workflow»),
- либо в URL‑параметрах/локальном сторе, если на это есть безопасное основание.
- React‑state используйте как кэш/слой UI, но будьте готовы, что при переключении режима он может обнулиться — тогда вы восстанавливаете его из более надёжного источника.
Вторая тонкость касается результата requestDisplayMode. Как уже упоминалось, запрос с mode "pip" может вернуться как "fullscreen", особенно на мобильных, где настоящий PiP может не поддерживаться или автоматически разворачиваться во весь экран.
Типичный шаблон:
const requestDisplayMode = useRequestDisplayMode();
const openPipSafe = async () => {
const result = await requestDisplayMode({ mode: "pip" });
if (result.mode !== "pip") {
// Fallback: например, показать сообщение или адаптировать UI под fullscreen
console.log("PiP недоступен, работаем в режиме:", result.mode);
}
};
Так вы не окажетесь в ситуации, когда рассчитывали на маленькое окошко, а получили полноэкранный UI с «пип‑специфическими» кнопками. В таком режиме такой интерфейс будет выглядеть странно.
Наконец, помните про maxHeight и внутренний скролл: даже в fullscreen хост может ограничивать высоту контейнера, и ваша задача — организовать скролл так, чтобы не появилось три вложенных полосы прокрутки.
11. Типичные ошибки при работе с fullscreen и PiP
Ошибка №1: Fullscreen как режим по умолчанию.
Некоторые разработчики видят слово «fullscreen» и сразу пытаются превратить своё App в отдельное SPA внутри чата. В результате любое упоминание подарков — и пользователь мгновенно улетает в полноэкранный мастер, хотя хотел просто пару идей. Гайдлайны OpenAI настойчиво рекомендуют начинать с inline и только при объективной необходимости расширяться до fullscreen.
Ошибка №2: PiP как маленький fullscreen.
У PiP очень ограниченная площадь, но иногда в него пытаются запихнуть всё: вкладки, формы, фильтры. Пользователь получает микроскопический интерфейс, по которому невозможно попасть мышкой. Правильный подход — в PiP показывать только статус и одну-две ключевые кнопки (например, «Развернуть» и «Отменить»).
Ошибка №3: Необъяснённые переходы между режимами.
Когда виджет внезапно раскрывается в fullscreen без текста от GPT или без явного клика пользователя, это дезориентирует. То же справедливо для автосворачивания в PiP или возврата в inline. Каждый переход следует сопровождать коротким пояснением в сообщении модели: «Сейчас открою подробный мастер» перед fullscreen, «Сверну подбор в маленькое окно, пока он считается» перед PiP.
Ошибка №4: Игнорирование мобильных и различий платформ.
Разработчик тестирует только на десктопе, где PiP ведёт себя ожидаемо, а потом на мобильном всё превращается в fullscreen, верстка «едет», а кнопки оказываются за пределами safe‑area. Документация прямо предупреждает, что PiP на мобильном может быть реализован как fullscreen, а поведение может меняться между версиями SDK, поэтому тестирование на целевых устройствах и аккуратная работа с requestDisplayMode обязательны.
Ошибка №5: Полная вера в сохранность состояния при смене режима.
Опора только на React‑state без какой-либо серверной/персистентной поддержки приводит к смешным ситуациям: пользователь прошёл два шага мастера, нажал «Свернуть в PiP», а после возврата оказался на первом шаге с пустыми полями. Лучше считать, что при смене режима ваш компонент могут размонтировать, и проектировать state‑менеджмент с учётом этого риска.
Ошибка №6: Забытая доступность fullscreen‑мастера.
Красивая форма на большом экране не всегда удобна людям с ослабленным зрением или тем, кто пользуется только клавиатурой. Слишком мелкий текст, низкий контраст, нечитаемые кнопки «Далее» и «Назад» — частые причины не только плохого UX, но и проблем на ревью в Store. Стоит проверить хотя бы базовые вещи: контраст текста, размер шрифта, работа Tab‑навигации и наличие понятных текстовых меток для кнопок.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ