2. Po co w ogóle fullscreen, skoro jest inline?
W poprzednim wykładzie o inline ustaliliśmy już: jeśli zadanie jest krótkie i mieści się w 5–7 elementach lub na jednym ekranie, karta inline to idealne rozwiązanie. Lista kilku prezentów, parę filtrów, jeden–dwa przyciski — to wszystko świetnie „żyje” bezpośrednio w strumieniu wiadomości.
Ale w każdej aplikacji nadchodzi moment, gdy „jeszcze jedna karta” już nie wystarcza:
- trzeba zebrać wiele parametrów (profil obdarowywanego, ograniczenia dostawy, metody płatności);
- potrzebny jest kreator z kilku kroków;
- są duże tabele, wykresy, mapy, długie opisy.
Inline zaczyna się tu „pocić”: szerokość ogranicza kolumna czatu, wysokość — również, nie ma nawigacji, a przewijanie czatu jest tylko jedno. Właśnie dla takich scenariuszy w Apps SDK istnieje tryb fullscreen — „zanurzony” interfejs, w którym wasz widżet zajmuje większość ekranu i może pokazywać złożony layout.
Drugi bohater dnia to PiP, małe pływające okno żyjące nad czatem. Typowe role: status zadania w tle, miniodtwarzacz, timer, wskaźnik postępu. PiP jest idealny, gdy coś długiego dzieje się „w tle”, a użytkownik nadal rozmawia z GPT.
Warto pamiętać: zarówno fullscreen, jak i PiP nie są zamiennikiem inline, lecz nadbudową. Zaczynamy od inline, a do fullscreen przechodzimy, gdy inline robi się ciasny; do PiP przechodzimy, gdy wszystko ważne już działa i trzeba jedynie „mieć na oku” status.
3. Fundament techniczny: displayMode i przełączanie trybów
Z perspektywy Apps SDK wasz widżet ma bieżący stan wyświetlania — displayMode. W momencie pisania kursu są trzy podstawowe tryby: "inline", "fullscreen" i "pip" (picture-in-picture).
Host (ChatGPT) przekazuje waszemu widżetowi aktualny tryb przez globalne dane w window.openai i specjalne hooki z SDK. W typowym szablonie React jest coś w tym stylu:
// alias z szablonu Apps SDK
const mode = useDisplayMode(); // 'inline' | 'fullscreen' | 'pip'
if (mode === "fullscreen") {
// renderujemy naszego kreatora
} else {
// renderujemy kompaktowy interfejs inline
}
SDK daje też metodę window.openai.requestDisplayMode({ mode }) i/lub hook useRequestDisplayMode, aby poprosić hosta o przełączenie trybu. Ta metoda zwraca promise z faktycznie ustawionym trybem, ponieważ platforma może odmówić lub skorygować waszą prośbę (na przykład PiP na urządzeniach mobilnych niemal zawsze zamienia się w fullscreen).
Schematycznie cykl życia trybów można przedstawić tak:
stateDiagram-v2
[*] --> Inline
Inline --> Fullscreen: requestDisplayMode('fullscreen')
Fullscreen --> Inline: requestDisplayMode('inline') / przycisk "Wstecz"
Fullscreen --> PiP: requestDisplayMode('pip')
PiP --> Fullscreen: "Rozwiń"
PiP --> Inline: zakończenie zadania
Rzeczywiste nazwy i dokładny zestaw trybów mogą zmieniać się wraz z wersjami SDK, dlatego w produkcji zawsze warto sprawdzić dokumentację zamiast polegać na „tak było w kursie”.
4. Pierwsze przełączenie: dodajemy przycisk „Rozwiń do pełnego ekranu”
Zacznijmy od małej rzeczy: weźmy nasz istniejący widżet inline GiftGenius — szkoleniową aplikację z poprzednich modułów, która obecnie pokazuje 3–5 kart prezentów — i dodajmy do niego przycisk „Otwórz szczegółowy dobór” do przejścia w fullscreen.
Załóżmy, że w szablonie mamy dwa hooki:
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" });
}}
/>
);
};
Tutaj InlineGiftPreview to nasz obecny interfejs inline, a GiftFullscreenWizard — nowy komponent‑kreator, który właśnie zaprojektujemy. W obsłudze onExpand nie tylko wywołujemy requestDisplayMode, ale też czekamy na promise — dzięki temu później zareagujemy na odmowę (na przykład pokażemy wiadomość, jeśli z jakiegoś powodu fullscreen jest niedostępny).
Sam InlineGiftPreview jest dość prosty:
type InlineGiftPreviewProps = {
onExpand: () => void;
};
const InlineGiftPreview: React.FC<InlineGiftPreviewProps> = ({ onExpand }) => {
return (
<div>
<h3>Dobór prezentów</h3>
{/* ...karty prezentów... */}
<button onClick={onExpand}>Otwórz szczegółowy dobór</button>
</div>
);
};
Na razie wszystko bardzo przypomina „otwarcie modala”, ale różnica polega na tym, że kontroluje to nie wasz React, lecz aplikacja‑host ChatGPT, i to ona może pokazywać tytuł, systemowe przyciski „Wstecz” itp.
5. Projektujemy kreator fullscreen GiftGenius
Teraz zaprojektujemy kreator fullscreen do doboru prezentu. Z punktu widzenia UX rozsądnie jest podzielić proces na kilka logicznych kroków. Na przykład:
- Kto otrzyma prezent i z jakiej okazji.
- Budżet i typ prezentów (fizyczne, przeżycia, cyfrowe).
- Weryfikacja i potwierdzenie wyboru.
W kodzie można to odzwierciedlić prostą maszyną stanów po krokach:
type WizardStep = "recipient" | "preferences" | "review";
type WizardState = {
step: WizardStep;
recipient?: { ageRange: string; relation: string };
preferences?: { budget: number; categories: string[] };
};
Utwórzmy komponent GiftFullscreenWizard, który przechowuje ten stan w React i renderuje właściwy ekran.
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} />;
};
Każdy krok to mały komponent z formularzem. Na przykład pierwszy krok:
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>Komu wybieramy prezent?</h2>
<input
placeholder="Kim ta osoba jest dla Ciebie?"
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
<input
placeholder="Wiek (np. 25–34)"
value={ageRange}
onChange={(e) => setAgeRange(e.target.value)}
/>
<button
onClick={() =>
onNext({
recipient: { relation, ageRange },
step: "preferences",
})
}
>
Dalej
</button>
</div>
);
};
W drugim kroku zbieramy budżet i kategorie, w trzecim — wywołujemy callTool / narzędzie MCP, które potrafi dobrać prezenty według tych parametrów, i pokazujemy wyniki.
Ważne, że na ekranie fullscreen mamy miejsce na:
- pasek postępu lub steppera;
- bardziej rozbudowane pola i podpowiedzi;
- stany błędów („coś poszło nie tak, spróbuj ponownie”).
Rekomendacja z wytycznych UX: każdy krok powinien pozostać maksymalnie prosty, bez przeładowania polami; lepsze 3–4 jasne kroki niż jeden potężny formularz‑potwór.
6. UX kreatora fullscreen: postęp, błędy, powrót
Samo wyświetlenie formularza na cały ekran to połowa sukcesu. Użytkownik potrzebuje:
- rozumieć, na którym jest kroku;
- mieć możliwość powrotu;
- widzieć, co dzieje się podczas długich operacji.
Najprostszy stepper można zrealizować czysto wizualnie:
const Stepper: React.FC<{ step: WizardStep }> = ({ step }) => {
const index = step === "recipient" ? 1 : step === "preferences" ? 2 : 3;
return <p>Krok {index} z 3</p>;
};
I po prostu wstawić Stepper do każdego ekranu. Bardziej zaawansowana wersja to wyrenderowanie poziomej „drabinki” kroków, ale w ramach kursu nie będziemy robić szkoły frontendu.
Ważny punkt — obsługa błędów. Załóżmy, że w ostatnim kroku wywołujemy narzędzie 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,
});
// Wyniki pojawią się później w czacie / widżecie
} catch (e) {
setError("Nie udało się dobrać prezentów, spróbuj ponownie.");
} finally {
setLoading(false);
}
};
return (
<div>
{/* pokazać podsumowanie parametrów */}
{error && <p style={{ color: "red" }}>{error}</p>}
<button disabled={loading} onClick={handleConfirm}>
{loading ? "Dobieramy…" : "Potwierdź i dobierz"}
</button>
</div>
);
};
Z punktu widzenia dostępności trzeba dopilnować, aby:
- w fullscreen duże przyciski „Dalej”, „Wstecz” i „Anuluj” były łatwe do kliknięcia;
- tekst miał odpowiedni kontrast;
- klawiszem Tab dało się przejść po kolei przez wszystkie elementy interaktywne.
Jeśli macie taką możliwość — warto dodać aria-label do niestandardowych kontrolek (np. własnych przełączników kategorii). Choć kurs nie jest egzaminem z WCAG, podstawowa dbałość o a11y pomoże później przejść review w Store bez niepotrzebnego bólu.
W efekcie kreator fullscreen rozwiązuje problem złożonych, wieloetapowych scenariuszy: daje miejsce na formularze, postęp i błędy. Ale życie aplikacji na tym się nie kończy — wiele zadań trwa „w tle”. Do tego mamy drugi tryb — PiP, o którym za chwilę.
7. Czym jest PiP w świecie ChatGPT i dlaczego jest „kapryśny”
Wiemy już, jak używać fullscreen do złożonych scenariuszy. Spójrzmy teraz na przypadek przeciwny — gdy wszystko ważne jest już uruchomione i trzeba tylko „mieć pod kontrolą” postęp. Wtedy do gry wchodzi PiP.
W świecie web „picture-in-picture” zwykle kojarzy się z wideo, które „wisi” w rogu ekranu nad treścią. W ChatGPT PiP to małe pływające okno widżetu, które pozostaje widoczne przy przewijaniu czatu i może pokazywać status, postęp lub kompaktowy UI.
Kilka ważnych cech, które trzeba znać z dokumentacji i doświadczeń early adopterów:
- PiP ma bardzo mało miejsca. To nie jest przestrzeń na formularze i złożone layouty, raczej na dwie–trzy kluczowe metryki i jeden–dwa przyciski.
- Na desktopie PiP „przykleja się” u góry i pozostaje widoczny przy każdym przewijaniu; na urządzeniach mobilnych często automatycznie zamienia się w fullscreen.
- Żądanie requestDisplayMode z mode "pip" nie gwarantuje prawdziwego PiP. Platforma może zwrócić inny tryb (np. fullscreen) albo zachować się dziwnie na starszych wersjach SDK, więc zawsze sprawdzaj wynik promise i miej fallback.
Z tego wynika prosty wniosek UX: w PiP pokazujemy tylko rzeczy najważniejsze. Timer, wskaźnik dostawy, status zadania, przycisk „Rozwiń”. Żadnych 12 checkboxów, tabel na 10 kolumn i „zrób mi jeszcze kawę”.
8. GiftGenius + PiP: długie wyszukiwanie i postęp w tle
Wróćmy do GiftGenius. Wyobraźmy sobie scenariusz: użytkownik przeszedł kreator fullscreen, kliknął „Potwierdź”, a teraz wasz backend uruchamia dość ciężki dobór — być może przez serwer MCP wywołujecie kilka zewnętrznych API, przeliczacie ceny, stosujecie mnóstwo filtrów. To może zająć, powiedzmy, 10–20 sekund.
Z punktu widzenia UX nie chcemy przez 20 sekund trzymać użytkownika w fullscreen z kręcącym się spinnerem. Lepiej:
- Uruchomić dobór.
- Zwinąć interfejs do PiP, pokazując postęp.
- Pozwolić użytkownikowi kontynuować czat (np. zadawać pytania doprecyzowujące).
- Po zakończeniu — zwrócić wynik inline albo otworzyć nowy fullscreen z prezentami.
Zróbmy prosty hook, który będzie tym zarządzał:
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("Rzeczywisty tryb:", resultMode.mode);
await callTool("run_gift_job", payload);
setStatus("done");
await requestDisplayMode({ mode: "inline" });
};
return { status, startJob };
};
Teraz w ReviewStep zamiast bezpośredniego callTool użyjemy tego hooka:
const ReviewStep: React.FC<StepProps> = ({ state }) => {
const { status, startJob } = useLongGiftJob();
return (
<div>
{/* ...podsumowanie... */}
<button
disabled={status === "running"}
onClick={() => startJob(state)}
>
{status === "running" ? "Dobieramy prezenty…" : "Uruchom dobór"}
</button>
</div>
);
};
Aby status zadania w tle był dostępny i dla kreatora fullscreen, i dla okna PiP, w prawdziwym kodzie warto wynieść useLongGiftJob do kontekstu i czytać go przez useLongGiftJobContext. Szczegóły implementacji kontekstu (Provider, createContext) pominiemy: ważne, że stan zadania żyje w jednym miejscu, a różne warstwy UI tylko się na niego zapisują.
Oraz osobny komponent do wyświetlania w PiP:
const GiftPipView: React.FC<{ status: string }> = ({ status }) => {
return (
<div>
<p>GiftGenius pracuje…</p>
<p>Status: {status === "running" ? "w toku" : "gotowe"}</p>
<button
onClick={() => window.openai.requestDisplayMode({ mode: "fullscreen" })}
>
Rozwiń
</button>
</div>
);
};
W ogólnym widżecie podmienimy render tak, aby uwzględniał także PiP:
const GiftGeniusWidget: React.FC = () => {
const mode = useDisplayMode();
const { status } = useLongGiftJobContext(); // przez kontekst, jak omawialiśmy wyżej
if (mode === "pip") {
return <GiftPipView status={status} />;
}
if (mode === "fullscreen") {
return <GiftFullscreenWizard />;
}
return <InlineGiftPreview onExpand={/* jak wcześniej */} />;
};
Taki scenariusz świetnie łączy się z trybami głosowymi (o nich będzie w wykładzie o voice): głosem uruchamiamy dobór, PiP pokazuje postęp, czat pozostaje na dole i żyje własnym życiem.
9. Wideo + czat: kiedy fullscreen i PiP stają się odtwarzaczem multimediów
Historycznie PiP najczęściej kojarzy się z wideo, które „wisi” w rogu ekranu nad treścią. Dlatego logiczne jest osobno omówić scenariusz „video + chat”. Tu również nie ma magii: w większości przypadków po prostu wyświetlacie wideo w fullscreen lub w oknie PiP. Dokumentacja OpenAI wprost podaje scenariusze multimedialne jako typowy przykład użycia fullscreen i PiP.
Co to może znaczyć dla GiftGenius? Na przykład:
- pokazujecie materiał promocyjny prezentu;
- krótki tutorial „jak ładnie zapakować prezent”;
- wideo‑recenzję kilku produktów.
W fullscreen można wyrenderować pełnoprawny <video> z opisem i rekomendacjami; w PiP — zostawić tylko sam odtwarzacz i ewentualnie mały nagłówek.
Najprostszy komponent‑wrapper:
const GiftVideoPlayer: React.FC<{ src: string; title: string }> = ({
src,
title,
}) => (
<div>
<h3>{title}</h3>
<video
src={src}
controls
style={{ width: "100%", borderRadius: 8 }}
/>
</div>
);
W kreatorze fullscreen możemy zaproponować użytkownikowi „Obejrzyj wideorecenzję tego prezentu”, a potem zwinąć ją do PiP:
const WatchVideoStep: React.FC = () => {
const requestDisplayMode = useRequestDisplayMode();
return (
<div>
<GiftVideoPlayer src="/videos/gift-wrap.mp4" title="Jak zapakować prezent" />
<button
onClick={() => requestDisplayMode({ mode: "pip" })}
>
Zostaw wideo w rogu i wróć do czatu
</button>
</div>
);
};
Kilka praktycznych porad dla scenariuszy multimedialnych:
- nie włączaj automatycznego odtwarzania z dźwiękiem — to uniwersalny antywzorzec UX;
- zadbaj o napisy i możliwość pauzowania z klawiatury (spacja, strzałki);
- w oknie PiP nie próbuj pokazywać całego towarzyszącego tekstu, ogranicz się do samego wideo.
10. Stan, ponowne tworzenie widżetu i specyfika mobilna
Najmniej przyjemne pytanie, które zwykle pada na tym etapie: „Czy stan React zostanie zachowany, jeśli przełączę się z inline do fullscreen i z powrotem?”
Krótka odpowiedź: nie polegaj na tym.
Technicznie zachowanie zależy od wersji SDK i implementacji hosta: w jednych przypadkach przejście między trybami następuje bez ponownego tworzenia iframe, w innych — widżet jest odmontowywany i montowany ponownie. W dokumentacji podkreśla się, że zachowanie kontekstu przy zmianie trybów zależy od konkretnej implementacji SDK i jego wersji i nie jest gwarantowane dla dewelopera.
Praktyczne podejście:
- Cały krytyczny stan (krok kreatora, wprowadzone dane, identyfikator zadania w tle) przechowuj:
- w backendzie (przez wasz serwer MCP i tokeny sesji),
- albo w kontekście ChatGPT (np. przez narzędzia, które zwracają „bieżący stan workflow”),
- albo w parametrach URL/local storage, jeśli macie ku temu bezpieczne podstawy.
- Stan React traktuj jako cache/warstwę UI, ale bądź gotów na to, że przy przełączeniu trybu może się wyzerować — wtedy odtwarzasz go z bardziej niezawodnego źródła.
Druga subtelność dotyczy wyniku requestDisplayMode. Jak już wspomniano, żądanie z mode "pip" może wrócić jako "fullscreen", zwłaszcza na urządzeniach mobilnych, gdzie prawdziwy PiP może nie być obsługiwany albo automatycznie rozciągać się na cały ekran.
Typowy wzorzec:
const requestDisplayMode = useRequestDisplayMode();
const openPipSafe = async () => {
const result = await requestDisplayMode({ mode: "pip" });
if (result.mode !== "pip") {
// Fallback: np. pokazać komunikat albo dostosować UI do fullscreen
console.log("PiP jest niedostępny, działamy w trybie:", result.mode);
}
};
Dzięki temu nie znajdziecie się w sytuacji, w której liczyliście na małe okienko, a dostaliście pełnoekranowy UI ze „specyficznymi dla PiP” przyciskami. W takim trybie taki interfejs będzie wyglądał dziwnie.
Wreszcie, pamiętaj o maxHeight i wewnętrznym przewijaniu: nawet w fullscreen host może ograniczać wysokość kontenera, a waszym zadaniem jest tak zorganizować scroll, aby nie powstały trzy zagnieżdżone paski przewijania.
11. Typowe błędy przy pracy z fullscreen i PiP
Błąd nr 1: Fullscreen jako tryb domyślny.
Niektórzy deweloperzy widzą słowo „fullscreen” i od razu próbują zamienić swoją aplikację w osobne SPA w czacie. W efekcie każde wspomnienie o prezentach — i użytkownik natychmiast ląduje w pełnoekranowym kreatorze, choć chciał tylko kilka pomysłów. Wytyczne OpenAI stanowczo zalecają zaczynać od inline i rozszerzać do fullscreen tylko przy obiektywnej potrzebie.
Błąd nr 2: PiP jako mały fullscreen.
PiP ma bardzo ograniczoną powierzchnię, ale czasem próbuje się weń wepchnąć wszystko: karty, formularze, filtry. Użytkownik dostaje mikroskopijny interfejs, w który trudno trafić myszą. Właściwe podejście — w PiP pokazywać tylko status i jeden–dwa kluczowe przyciski (np. „Rozwiń” i „Anuluj”).
Błąd nr 3: Nieobjaśnione przejścia między trybami.
Gdy widżet nagle rozwija się do fullscreen bez tekstu od GPT lub bez wyraźnego kliknięcia użytkownika, to dezorientuje. To samo dotyczy automatycznego zwinięcia do PiP lub powrotu do inline. Każde przejście należy opatrzyć krótkim wyjaśnieniem w wiadomości modelu: „Teraz otworzę szczegółowy kreator” przed fullscreen, „Zwinę dobór do małego okna na czas liczenia” przed PiP.
Błąd nr 4: Ignorowanie urządzeń mobilnych i różnic między platformami.
Deweloper testuje tylko na desktopie, gdzie PiP zachowuje się przewidywalnie, a potem na mobilnym wszystko zamienia się w fullscreen, layout „pływa”, a przyciski wypadają poza safe area. Dokumentacja wprost ostrzega, że PiP na urządzeniach mobilnych może być zaimplementowany jako fullscreen, a zachowanie może się różnić między wersjami SDK, dlatego testy na docelowych urządzeniach i ostrożna praca z requestDisplayMode są obowiązkowe.
Błąd nr 5: Pełna wiara w trwałość stanu przy zmianie trybu.
Poleganie wyłącznie na stanie React bez jakiegokolwiek wsparcia serwerowego/persistentnego prowadzi do zabawnych sytuacji: użytkownik przeszedł dwa kroki kreatora, kliknął „Zwiń do PiP”, a po powrocie znalazł się na pierwszym kroku z pustymi polami. Lepiej założyć, że przy zmianie trybu wasz komponent mogą odmontować i zaprojektować zarządzanie stanem z uwzględnieniem tego ryzyka.
Błąd nr 6: Zapomniana dostępność kreatora fullscreen.
Ładny formularz na dużym ekranie nie zawsze jest wygodny dla osób z osłabionym wzrokiem lub korzystających tylko z klawiatury. Zbyt mały tekst, niski kontrast, nieczytelne przyciski „Dalej” i „Wstecz” — to częste przyczyny nie tylko złego UX, ale i problemów podczas review w Store. Warto sprawdzić przynajmniej podstawy: kontrast tekstu, rozmiar czcionki, działanie nawigacji Tab oraz obecność zrozumiałych etykiet tekstowych dla przycisków.
GO TO FULL VERSION