1. Po co w ogóle zarządzać wyglądem
Twój widget najpewniej wygląda teraz jak „normalny komponent React”: jakiś div, lista elementów, para przycisków. W zwykłym webie to często wystarcza. W ChatGPT jest jednak niuans: twój UI żyje wewnątrz czatu, gdzie użytkownik ma już sporo kontekstu wizualnego — wiadomości, inne Apps, interfejs głosowy — plus ograniczenia rozmiaru kontenera.
Warto pamiętać o dwóch rzeczach.
Po pierwsze, widget ma tryb wyświetlania (displayMode): inline, fullscreen, czasem PiP. Od trybu zależą dostępna powierzchnia, zachowanie przewijania i oczekiwania użytkownika.
Po drugie, platforma przekazuje widgetowi ograniczenia wysokości (maxHeight) oraz motyw (theme). Jeśli je zignorujesz i narysujesz coś wielkości Notion w ramach jednej wiadomości, czat zmienia się w „czarną dziurę”, gdzie wszystko tonie w jednym ogromnym iframe. OpenAI wprost rekomenduje robić UI zwięzły i respektować systemowe kolory/typografię.
Typowy scenariusz GiftGenius dobrze pokazuje to w praktyce. Użytkownik prosi: „Dobierz prezent dla przyjaciela do $50”. ChatGPT uruchamia GiftGenius, który w trybie inline pokazuje kompaktowe karty prezentów i parę przycisków. Użytkownik klika „Szczegóły” — widżet żąda fullscreen i dopiero tam pokazuje filtry, szczegółowy opis, recenzje. Gdy trwa finalizacja zakupu, można pokazać małe okno PiP/modal z komunikatem „Przetwarzamy zamówienie…”, nie zasłaniając całego czatu.
Naszym celem w tym wykładzie jest nauczyć się:
- rozumieć, jaki jest aktualnie displayMode, i adekwatnie się do niego dostosować;
- na żądanie przełączać tryb (inline ↔ fullscreen, czasem PiP);
- respektować maxHeight i nie robić „podwójnego scrolla”;
- adaptować style do motywu jasnego/ciemnego i szerokości ekranu;
- budować layout, który wygląda „natomiast” wewnątrz ChatGPT.
2. Tryby displayMode: inline, fullscreen, PiP
Zacznijmy od pojęć. displayMode to stan kontenera twojego widżetu w ChatGPT. Przychodzi z platformy (przez window.openai.displayMode lub hook useDisplayMode) i może przyjmować wartości takie jak "inline", "fullscreen", "pip".
Inline
Inline — tryb domyślny. Widżet jest wstawiany bezpośrednio w strumień wiadomości jako kolejny „blok” między odpowiedziami tekstowymi. Szerokość jest ograniczona szerokością kolumny czatu (na desktopie ~700–800px, w telefonie — szerokość ekranu), a wysokość dynamiczna, ale nie nieskończona.
Inline idealnie nadaje się do:
- krótkich, samowystarczalnych prezentacji: karty prezentów, lista opcji, podsumowanie wyszukiwania;
- jednej–dwóch akcji: „Wybierz”, „Anuluj”, „Pokaż więcej”.
Dla GiftGenius to główny tryb: użytkownik napisał prośbę, a ty pokazujesz 3–5 kart prezentów z przyciskami i nie przejmujesz całego ekranu.
Fullscreen (Canvas)
Fullscreen (lub canvas) — tryb, w którym twój widżet zajmuje większość widocznego obszaru. Czat nie znika: pasek wprowadzania nadal jest dostępny, ale główna uwaga skupia się na twoim UI.
Włączać fullscreen warto, gdy:
- jest dużo pól wejściowych lub złożony kreator (finalizacja zamówienia, rozbudowane filtry, ustawienia);
- trzeba pokazać duże tabele, mapy, porównanie dziesiątek elementów;
- inline już się nie mieści i zaczyna wyglądać jak mini‑Excel o wysokości 700px.
W GiftGenius fullscreen służy do dania użytkownikowi pełnych filtrów, sortowania, szczegółowych opisów, być może kilku zakładek.
PiP / Modal
PiP (picture-in-picture) i modale to niewielkie „pływające” okna nad główną treścią. W aktualnych implementacjach Apps SDK PiP bywa realizowane albo jako szczególny tryb displayMode, albo jako okno modalne przez requestModal().
Przydają się, gdy:
- trzeba pokazać status długotrwałego procesu (przetwarzanie zamówienia, renderowanie wideo);
- trzeba o coś szybko zapytać bez przerywania głównego przebiegu (szybkie potwierdzenie);
- chcesz dać użytkownikowi możliwość „trzymania widżetu na wierzchu”, kontynuując czat.
W GiftGenius może to być mały panel „Przetwarzamy zamówienie… 30%” z przyciskiem „Anuluj”.
Małe porównanie
Tabela dla łatwiejszego odbioru:
| Tryb | Gdzie się znajduje | Typowe przypadki użycia | Ograniczenia |
|---|---|---|---|
|
w strumieniu wiadomości | Listy, karty, jeden–dwa przyciski | Ograniczona wysokość, wąska szerokość |
|
nad czatem / z boku | Kreatory, złożone formularze, tabele | Wymaga przemyślanego układu i nawigacji |
| PiP / modal | pływająca warstwa | Status, mini‑formularze, wideo | Bardzo mało miejsca; wszystko musi być duże i proste |
Ważne, by nie traktować fullscreen jak „prawdziwej aplikacji”, a inline jak „podglądu”. To ta sama App, tylko w różnych „pozycjach”.
3. Hooki do pracy z trybem: useDisplayMode, useRequestDisplayMode, useRequestModal
Teraz, gdy wiemy, czym są inline/fullscreen/PiP z perspektywy UX, zobaczmy, jak nimi sterować z kodu przez hooki Apps SDK.
Zamiast czytać window.openai.displayMode bezpośrednio, używamy hooka z szablonu, który subskrybuje zmiany i oszczędza rytuałów z wydarzeniami SDK. Typowy interfejs wygląda tak:
// pseudotypy; rzeczywiste nazwy sprawdź w szablonie
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
function useDisplayMode() {
// zwraca bieżący tryb
return { displayMode: 'inline' as DisplayMode };
}
function useRequestDisplayMode() {
// funkcja‑żądanie zmiany trybu
return {
requestDisplayMode: (mode: DisplayMode) => {
/* wywołuje window.openai.requestDisplayMode */
},
};
}
Zróbmy prosty komponent, który pokazuje bieżący tryb i daje przycisk „Rozwiń / Zwiń”:
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>Tryb: {displayMode}</span>
<button onClick={toggle} className="underline">
Przełącz
</button>
</div>
);
}
W prawdziwych aplikacjach takie „debugowe” elementy zwykle chowasz, ale w Dev Mode taki komponent świetnie pomaga poczuć, jak widżet się zachowuje przy przełączaniu.
Inline vs fullscreen różnymi podkomponentami
Częsty błąd — próbować jednym i tym samym layoutem obsłużyć wszystkie tryby i wrzucać do JSX mnóstwo if (displayMode === ...). O wiele czytelniej jest rozdzielić widok:
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 />;
}
Kod czyta się wtedy jako „jeśli fullscreen — tu jest złożony kreator, w przeciwnym razie — kompaktowy inline”. Każdy podkomponent można wystylować osobno pod swoje ograniczenia. Takie podejście jest właśnie zalecane w planie modułu: rozdzielać tryby na osobne podkomponenty zamiast ogromnego if/else w jednym komponencie.
Modale: useRequestModal
Jeśli szablon daje hook useRequestModal, jego interfejs zwykle wygląda tak:
const { requestModal } = useRequestModal();
// requestModal({ title }) albo coś w tym stylu.
Modale są podobne do fullscreen, ale go nie zastępują: fullscreen jest do dużych scenariuszy, modal — do jednego krótkiego kroku (potwierdzenie akcji, wprowadzenie kodu kuponu itp.).
4. Kontrola rozmiarów: maxHeight, scroll i notifyIntrinsicHeight()
Druga ważna oś — wysokość. Platforma mówi widżetowi: „Oto maksymalnie dostępna wysokość”. Ten limit można odczytać w window.openai.maxHeight lub przez hook useMaxHeight.
Dlaczego nie można po prostu ustawić „height: 5000px”
Jeśli zignorujesz maxHeight i ustawisz ogromną stałą wysokość, ChatGPT będzie zmuszony przyciąć twoją treść. Albo da użytkownikowi podwójny scroll: zewnętrzny — czatu, wewnętrzny — twojego widżetu. To słaby UX: użytkownik musi zgadywać, gdzie dokładnie przewijać, żeby dotrzeć do właściwego przycisku.
Prawidłowa strategia wygląda tak:
- Odczytywać limit maxHeight.
- Budować layout tak, by główne przewijanie pozostało po stronie czatu (zwłaszcza w inline).
- W fullscreen można pozwolić na odrobinę wewnętrznego przewijania, ale ostrożnie.
useMaxHeight i ograniczenie kontenera
Napiszmy prostą otoczkę, która ustawia maksimum wysokości dla korzeniowego kontenera:
import { useMaxHeight } from '@/apps-sdk';
export function WidgetContainer(props: { children: React.ReactNode }) {
const { maxHeight } = useMaxHeight(); // na przykład 600
return (
<div
style={{ maxHeight }}
className="overflow-y-auto p-4 bg-background border border-border rounded-xl"
>
{props.children}
</div>
);
}
Tutaj uczciwie ograniczamy wysokość i włączamy pionowe przewijanie wewnątrz kontenera, ale w rozsądnych granicach. W praktyce w inline lepiej unikać dużego wewnętrznego przewijania i zamiast ogromnych list pokazywać część danych z przyciskiem „Pokaż więcej” lub proponować fullscreen.
Dynamiczna wysokość i notifyIntrinsicHeight()
Jeszcze jeden niuans: twoja treść może zmieniać rozmiar w czasie. Na przykład najpierw pokazujesz spinner „Ładujemy prezenty…”, potem — listę 10 kart, a potem użytkownik zwija/rozwija filtry. Aby ChatGPT poprawnie przydzielał miejsce pod widżet i go nie przycinał, trzeba przy zmianie wysokości zgłaszać do hosta nową wartość. Do tego służy notifyIntrinsicHeight().
W szablonie jest to często opakowane w hook podobny do useAutoResize. Można go zrealizować mniej więcej tak:
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;
}
I użycie:
export function GiftListInline() {
const containerRef = useAutoResize();
return (
<div ref={containerRef}>
{/* twoja zawartość */}
</div>
);
}
Pomysł jest prosty: kiedy twój korzeniowy div zmienia wysokość, wołasz API SDK, a ChatGPT dostosowuje kontener. Taki wzorzec jest wprost polecany przez doświadczonych deweloperów: „otoczka‑auto‑resizer” wokół całej zawartości.
Mały schemat
Przedstawmy to jako schemat blokowy:
flowchart TD
A[Zawartość widżetu się zmieniła] --> B[ResizeObserver rejestruje nową wysokość]
B --> C["Wywołanie notifyIntrinsicHeight(newHeight)"]
C --> D[ChatGPT zwiększa/zmniejsza kontener]
D --> E[Użytkownik widzi schludne przewijanie bez przycięć]
Z rozmiarami i wysokością mamy jasność: widżet nie powinien wyjeżdżać poza przydzieloną przestrzeń ani fundować użytkownikowi zagadki z podwójnym scrollem.
5. Motyw (theme), kolory i obramowania: jak zrobić widżet „rodzimy”
Jeśli displayMode i maxHeight określają, ile miejsca mamy, to motyw (theme) i paleta decydują o tym, jak ten fragment interfejsu wygląda w czacie.
ChatGPT obsługuje co najmniej motyw jasny i ciemny. Platforma przekazuje to do twojego widżetu przez window.openai.theme i/lub w _meta["openai/theme"], a w szablonie React jest hook useOpenAiGlobal("theme") albo coś w rodzaju useTheme.
Główna myśl: twój UI powinien dostosowywać się do motywu, a nie narzucać własny.
Pobieranie motywu
Przykład prostego hooka:
import { useOpenAiGlobal } from '@/apps-sdk';
export function useThemeMode() {
const theme = useOpenAiGlobal<'light' | 'dark'>('theme') ?? 'light';
return { theme };
}
W komponencie:
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>
);
}
W prawdziwym projekcie prawdopodobnie używasz Tailwind z darkMode: 'class' i wieszasz klasę dark na korzeniu widżetu. Ale sedna to nie zmienia: motyw przychodzi z Apps SDK, a nie żyje „sam z siebie”.
Kolory, ramki i typografia
Według wytycznych OpenAI:
- używaj czcionek systemowych i dbałej typografii;
- nie nadpisuj agresywnie systemowych kolorów;
- widżet ma być „rodzimym” elementem czatu, a nie osobnym landingiem z ostrym gradientem.
Dobry wzorzec dla kontenera 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">
Wybierz
</button>
</div>
);
}
Zakładamy, że bg-background, border-border, text-foreground, bg-primary itd. to zmienne CSS/klasy utility związane z motywem ChatGPT. Takie podejście opisują też rekomendacje: używać zmiennych i klas powiązanych z motywem, a nie twardo wpisywać kolory.
6. Układ i responsywność: desktop, mobile, PiP
Trzecia oś — szerokość i urządzenie. Upraszczając, wygląd widżetu określają tryb (displayMode), dostępna wysokość (maxHeight) i dostępna szerokość (desktop/mobile/PiP).
W tej sekcji zajmiemy się trzecim parametrem. Na desktopie widget inline ma jedną szerokość, na mobile — inną; w PiP miejsca jest naprawdę mało. Apps SDK przekazuje sygnały takie jak userAgent, safeArea, czasem rozmiar kontenera, które można czytać przez useOpenAiGlobal.
Ogólne zasady
Kilka ważnych zasad poniżej.
Po pierwsze, nie zakładaj stałej szerokości. Ekran użytkownika może być wąski (telefon) lub szeroki (duży desktop). Dlatego lepiej budować układ na flex/grid z auto-fit niż na sztywnym width: 400px.
Po drugie, unikaj poziomego przewijania. Jeśli twoja tabela lub karty się nie mieszczą, lepiej przejść w fullscreen albo pokazać skróconą wersję. Można też użyć karuzeli ze slajdami.
Po trzecie, pamiętaj, że PiP/modale są często bardzo wąskie i nie zmieści się tam duży formularz — fizycznie trudno trafić w pola.
Te kwestie są wyraźnie podkreślane w dokumentacji: responsywność, safeArea, różnice desktop vs mobile i ryzyko przeładowanych układów.
Różne układy dla inline i fullscreen
Wróćmy do GiftGenius. Lista prezentów w inline i fullscreen może wyglądać zupełnie inaczej. Zróbmy dwa komponenty.
Kompaktowy inline: maksymalnie 3 karty, jedna kolumna na mobile i dwie na szerokim ekranie.
export function GiftListInline() {
const gifts = useGiftData(); // umowny hook, bierzemy z toolOutput
return (
<WidgetContainer>
<h2 className="text-base font-semibold mb-3">
Propozycje prezentów
</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">
Pokazano pierwsze 3 opcje. Rozwiń widżet, aby zobaczyć wszystko.
</p>
)}
</WidgetContainer>
);
}
I wersja fullscreen: siatka, filtry, więcej kart.
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">
Prezenty dla ciebie
</h1>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Filtr według nazwy"
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>
);
}
Tutaj dopuszczamy wewnętrzny pionowy scroll treści fullscreen (overflow-y-auto na main), co jest normalne dla trybu pełnoekranowego. Wersja inline — zgodnie z wytycznymi — pozostaje kompaktowa i czytelna „w 2 sekundy”.
Schemat: zachowanie w zależności od trybu
Dla utrwalenia narysujmy prosty diagram:
stateDiagram-v2
[*] --> Inline
Inline: 3 karty, minimum tekstu
Inline --> Fullscreen: Klik „Rozwiń” / „Pokaż wszystko”
Fullscreen: Siatka, filtry, dużo danych
Fullscreen --> Inline: Przycisk „Zamknij” / działanie hosta
Fullscreen --> PiP: Długa operacja, pokaż postęp
PiP: Mały panel statusu
PiP --> Inline: Operacja zakończona, pokazujemy końcową wiadomość
Taki scenariusz jest bardzo zbliżony do opisanych wzorców UX: inline jako teaser, fullscreen jako narzędzie robocze, PiP jako wskaźnik procesu.
7. Praktyka: dwa tryby tego samego widżetu
Czas to utrwalić w kodzie. W ramach praktyki w tym wykładzie warto zrobić dwa kroki w bieżącej aplikacji szkoleniowej.
Krok 1. Widżet inline z kartami
Rozszerz bieżący GiftGenius tak, aby w trybie inline widżet:
- pokazywał nagłówek „Propozycje prezentów”;
- wyświetlał do trzech kart prezentów z toolOutput;
- pokazywał podpowiedź „Rozwiń widżet, aby zobaczyć wszystko”, jeśli prezentów jest więcej niż trzy;
- starannie dostosowywał wysokość przez useAutoResize i notifyIntrinsicHeight().
Przy tym style powinny opierać się na motywie: użyj klas lub zmiennych związanych z theme, a nie twardych kolorów.
Krok 2. Wersja fullscreen z formularzem
Następnie dodaj widok fullscreen, który:
- pokazuje nagłówek + wyszukiwarkę po nazwie;
- wyświetla wszystkie prezenty w siatce;
- pozwala na pionowe przewijanie wewnątrz głównego obszaru;
- zapewnia przycisk „Wróć do dialogu” (który woła requestDisplayMode('inline')).
Kompozycja może wyglądać tak:
export function GiftGeniusWidget() {
const { displayMode } = useDisplayMode();
return (
<>
<DisplayModeDebug />
{displayMode === 'fullscreen' ? (
<GiftListFullscreen />
) : (
<GiftListInline />
)}
</>
);
}
W ChatGPT Dev Mode będziesz mógł ręcznie przełączać tryb lub zażądać fullscreen programowo po kliknięciu „Pokaż wszystko” w wersji inline (przez useRequestDisplayMode). To ćwiczenie pomoże zrozumieć, jak ta sama App może wyglądać i zachowywać się różnie w zależności od displayMode.
8. Typowe błędy przy zarządzaniu wyglądem widżetu
Zanim ruszymy dalej z kursem, zapiszmy kilka typowych min związanych z displayMode, rozmiarami, motywem i układem. Jeśli unikać ich od początku, życie z Apps SDK będzie znacznie przyjemniejsze.
Błąd nr 1: Ignorowanie displayMode i próba „na siłę” zrobić wszystko fullscreen‑podobne.
Czasem deweloperzy rysują jeden ciężki layout (prawie jak osobne SPA), który ledwo mieści się w inline. W efekcie użytkownik widzi miniaturowy Notion z przewijaniem i milionem elementów. Poprawne podejście — projektować różne widoki pod różne tryby i respektować fakt, że inline to kompaktowy, „jednoekranowy” format.
Błąd nr 2: Ogromna stała wysokość i podwójny scroll.
Ustawić height: 800px i zapomnieć o maxHeight — prosty sposób na to, by widżet został przycięty lub wygenerował jednocześnie wewnętrzne i zewnętrzne przewijanie. Użytkownik zacznie „łapać” właściwy pasek przewijania, co mocno psuje UX. Zamiast tego trzeba czytać maxHeight, ograniczać przez max-height i przy zmianach wysokości zgłaszać je przez notifyIntrinsicHeight().
Błąd nr 3: Ignorowanie motywu i próba „przemalować wszystko pod markę”.
Jeśli ustawiasz własne fonty, tła, kontrastowe gradienty i całkowicie ignorujesz jasny/ciemny motyw ChatGPT, łamiesz spójność wizualną platformy. Wytyczne jasno mówią: używać systemowych kolorów i fontów, a markę wnosić delikatnymi akcentami (przycisk, ikona, logo). Śledź theme przez hook i dostosowuj paletę.
Błąd nr 4: Zbyt złożony UI w PiP/modalach.
Próba upchnięcia całego formularza z wieloma polami w małym oknie PiP to droga donikąd. Tam pasują tylko bardzo proste przypadki: postęp procesu, jedna–dwie akcje, jedno pole wejściowe. Reszta — kandydaci do fullscreen.
Błąd nr 5: Twarde projektowanie pod 800px i brak testów na mobile.
Sztywne projektowanie pod 800px i przekonanie, że „jakoś się zmieści na telefonie”. W praktyce klient mobilny ChatGPT ma zupełnie inną szerokość i zachowanie, a PiP jest jeszcze węższy. Nie zapominaj o userAgent/safeArea, używaj grid/flex bez sztywnej szerokości i choć raz spójrz na swój widżet przy wąskim układzie.
Błąd nr 6: Praca bezpośrednio z window.openai bez hooków.
Formalnie możesz napisać const mode = window.openai.displayMode, ale wtedy sam będziesz subskrybować wydarzenia, myśleć o aktualizacjach React i łapać bugi, jeśli SDK coś zmieni. Hooki (useDisplayMode, useMaxHeight, useOpenAiGlobal, useRequestDisplayMode) powstały właśnie po to, by schować tę rutynę i utrzymać kod w czystości. Lepiej ich używać i żyć spokojnie.
GO TO FULL VERSION