JavaRush /Курсы /ChatGPT Apps /Управление внешним видом: ...

Управление внешним видом: displayMode, maxHeight, borders, theme, layout

ChatGPT Apps
3 уровень , 1 лекция
Открыта

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%» с кнопкой «Отменить».

Небольшое сравнение

Таблица для визуального восприятия:

Режим Где живёт Типичные кейсы Ограничения
inline
в потоке сообщений Списки, карточки, одна-две кнопки Ограниченная высота, узкая ширина
fullscreen
над чатом / сбоку Мастера, сложные формы, таблицы Требует осмысленного 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: пользователю приходится угадывать, где именно нужно скроллить, чтобы дойти до нужной кнопки.

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

  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) и доступной шириной (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) сделаны специально, чтобы спрятать эту рутину и держать код чище. Лучше использовать их и жить спокойно.

1
Задача
ChatGPT Apps, 3 уровень, 1 лекция
Недоступна
Бейдж режима и темы + переключатель inline/fullscreen
Бейдж режима и темы + переключатель inline/fullscreen
1
Задача
ChatGPT Apps, 3 уровень, 1 лекция
Недоступна
SafeAreaViewport — контейнер, который уважает maxHeight и safeArea
SafeAreaViewport — контейнер, который уважает maxHeight и safeArea
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ