2. Por que precisamos de fullscreen se já existe inline?
Na aula anterior sobre inline, já combinamos: se a tarefa é curta e cabe em 5–7 itens ou uma única tela, o card inline é a opção ideal. Uma lista com alguns presentes, alguns filtros, um ou dois botões — tudo isso vive muito bem diretamente no fluxo de mensagens.
Mas chega um momento em qualquer aplicativo em que “mais um card” já não resolve:
- é preciso coletar muitos parâmetros (perfil do destinatário, restrições de entrega, formas de pagamento);
- é necessário um assistente com vários passos;
- há grandes tabelas, gráficos, mapas, descrições longas.
Aqui o inline começa a ficar apertado: a largura é limitada pela coluna do chat, a altura também, não há navegação, e o chat tem uma única barra de rolagem. É exatamente para esses cenários que o Apps SDK oferece o modo fullscreen — uma interface “imersiva” na qual seu widget ocupa a maior parte da tela e pode exibir um layout complexo.
A segunda estrela do dia é o PiP, uma pequena janela flutuante que fica por cima do chat. Papéis típicos: status de uma tarefa em segundo plano, mini player, timer, indicador de progresso. O PiP é ideal quando algo demorado está acontecendo “ao fundo” e o usuário continua conversando com o GPT.
É importante lembrar: fullscreen e PiP não substituem o inline, são uma camada adicional. Começamos com inline e migramos para fullscreen quando o inline fica apertado; vamos para PiP quando o que importa já está em andamento e basta “manter o status à vista”.
3. Fundamentos técnicos: displayMode e trocas de modo
Do ponto de vista do Apps SDK, seu widget tem um estado de exibição atual — displayMode. No momento em que esta aula foi escrita, há três modos principais: "inline", "fullscreen" e "pip" (picture-in-picture).
O host (ChatGPT) informa ao seu widget o modo atual por meio de dados globais em window.openai e hooks especiais do SDK. Em um template típico de React, há algo como:
// alias do template do Apps SDK
const mode = useDisplayMode(); // 'inline' | 'fullscreen' | 'pip'
if (mode === "fullscreen") {
// renderizamos nosso assistente
} else {
// renderizamos o UI inline compacto
}
O SDK também fornece o método window.openai.requestDisplayMode({ mode }) e/ou o hook useRequestDisplayMode para solicitar ao host a troca de modo. Esse método retorna uma promise com o modo efetivamente aplicado, porque a plataforma pode recusar ou ajustar seu pedido (por exemplo, no mobile o PiP quase sempre vira fullscreen).
De forma esquemática, o ciclo de vida dos modos pode ser representado assim:
stateDiagram-v2
[*] --> Inline
Inline --> Fullscreen: requestDisplayMode('fullscreen')
Fullscreen --> Inline: requestDisplayMode('inline') / botão "Voltar"
Fullscreen --> PiP: requestDisplayMode('pip')
PiP --> Fullscreen: "Expandir"
PiP --> Inline: conclusão da tarefa
Os nomes reais e o conjunto exato de modos podem mudar com as versões do SDK, então em produção vale sempre conferir a documentação, e não se apoiar no “como foi na aula”.
4. Primeira troca: criando o botão “Expandir para tela cheia”
Vamos começar pequeno: pegar nosso widget inline já existente, o GiftGenius — o App didático de módulos anteriores, que hoje mostra 3–5 cards de presentes — e adicionar nele um botão “Abrir seleção detalhada” para ir ao fullscreen.
Vamos supor que temos dois hooks no template:
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" });
}}
/>
);
};
Aqui, InlineGiftPreview é nosso UI inline atual, e GiftFullscreenWizard é o novo componente assistente, que vamos projetar agora. No handler de onExpand não apenas chamamos requestDisplayMode, mas também aguardamos a promise — assim poderemos reagir depois a uma recusa (por exemplo, mostrar uma mensagem se, por algum motivo, o fullscreen estiver indisponível).
O próprio InlineGiftPreview é bastante simples:
type InlineGiftPreviewProps = {
onExpand: () => void;
};
const InlineGiftPreview: React.FC<InlineGiftPreviewProps> = ({ onExpand }) => {
return (
<div>
<h3>Seleção de presentes</h3>
{/* ...cards de presentes... */}
<button onClick={onExpand}>Abrir seleção detalhada</button>
</div>
);
};
Até aqui, parece muito com “abrir um modal”, mas a diferença é que quem controla não é o seu React, e sim o aplicativo host do ChatGPT, que pode exibir título, botões de sistema “Voltar” etc.
5. Projetando o assistente em fullscreen do GiftGenius
Agora vamos projetar o assistente em fullscreen para a escolha de presentes. Do ponto de vista de UX, faz sentido dividir o processo em alguns passos lógicos. Por exemplo:
- Quem é o destinatário e qual é a ocasião.
- Orçamento e tipo de presentes (físicos, experiências, digitais).
- Revisão e confirmação da escolha.
No código, isso pode ser representado por uma máquina de estados simples por passos:
type WizardStep = "recipient" | "preferences" | "review";
type WizardState = {
step: WizardStep;
recipient?: { ageRange: string; relation: string };
preferences?: { budget: number; categories: string[] };
};
Vamos criar o componente GiftFullscreenWizard, que armazena esse estado no React e renderiza a tela adequada.
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} />;
};
Cada passo é um pequeno componente com um formulário. Por exemplo, o primeiro passo:
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>Para quem estamos escolhendo o presente?</h2>
<input
placeholder="Quem é essa pessoa para você?"
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
<input
placeholder="Idade (por exemplo, 25–34)"
value={ageRange}
onChange={(e) => setAgeRange(e.target.value)}
/>
<button
onClick={() =>
onNext({
recipient: { relation, ageRange },
step: "preferences",
})
}
>
Avançar
</button>
</div>
);
};
No segundo passo, coletamos o orçamento e as categorias; no terceiro, chamamos um callTool / ferramenta MCP que já sabe selecionar presentes com esses parâmetros e exibimos os resultados.
É importante que na tela em fullscreen tenhamos espaço para:
- uma barra de progresso ou um stepper;
- campos e dicas mais detalhados;
- estados de erro (“algo deu errado, tente novamente”).
Recomendação de UX: cada passo deve permanecer o mais simples possível, sem sobrecarga de campos; é melhor ter 3–4 passos claros do que um formulário “monstro”.
6. UX do assistente em fullscreen: progresso, erros, retorno
Apenas exibir um formulário em tela cheia é metade do trabalho. O usuário precisa:
- entender em qual passo está;
- ter a possibilidade de voltar;
- ver o que acontece durante operações demoradas.
Um stepper simples pode ser implementado apenas visualmente:
const Stepper: React.FC<{ step: WizardStep }> = ({ step }) => {
const index = step === "recipient" ? 1 : step === "preferences" ? 2 : 3;
return <p>Etapa {index} de 3</p>;
};
E basta inserir o Stepper em cada tela. Uma variação mais avançada seria renderizar uma “escada” horizontal de passos, mas, no escopo desta aula, não vamos fazer uma escola de layout.
Um ponto importante é o tratamento de erros. Suponha que, no último passo, chamamos a ferramenta 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,
});
// Os resultados aparecerão depois no chat / widget
} catch (e) {
setError("Não foi possível selecionar os presentes, tente novamente.");
} finally {
setLoading(false);
}
};
return (
<div>
{/* mostrar o resumo dos parâmetros */}
{error && <p style={{ color: "red" }}>{error}</p>}
<button disabled={loading} onClick={handleConfirm}>
{loading ? "Buscando…" : "Confirmar e selecionar"}
</button>
</div>
);
};
Do ponto de vista de acessibilidade, é preciso garantir que:
- no fullscreen, os botões grandes “Avançar”, “Voltar” e “Cancelar” sejam fáceis de clicar;
- o texto tenha contraste adequado;
- seja possível percorrer todos os elementos interativos na ordem usando Tab.
Se possível, vale adicionar aria-label para controles não padronizados (por exemplo, seletores de categorias personalizados). Embora a aula não vire um exame de WCAG, uma atenção básica a a11y ajuda a passar a revisão na Store sem dor de cabeça.
No fim, o assistente em fullscreen resolve cenários complexos de múltiplos passos: fornece espaço para formulários, progresso e erros. Mas a vida do aplicativo não termina aí — muitas tarefas continuam “em segundo plano”. Para isso, temos o segundo modo — PiP, do qual falaremos a seguir.
7. O que é PiP no mundo do ChatGPT e por que ele é “temperamental”
Já entendemos como usar fullscreen para cenários complexos. Agora, vejamos o caso oposto — quando tudo o que importa já foi iniciado e só precisamos “manter sob controle” o progresso. É aqui que entra o PiP.
No mundo web, “picture-in-picture” geralmente é associado a vídeo que fica em um canto da tela por cima do conteúdo. No ChatGPT, PiP é uma pequena janela flutuante do widget, que permanece visível ao rolar o chat e pode exibir status, progresso ou um UI compacto.
Algumas características importantes que você precisa saber pela documentação e pela experiência de early adopters:
- O PiP tem pouquíssimo espaço. Não é um lugar para formulários e layouts complexos, mas sim para duas ou três métricas-chave e um ou dois botões.
- No desktop, o PiP “gruda” no topo e permanece visível em qualquer rolagem; já no mobile, ele frequentemente se transforma automaticamente em fullscreen.
- Pedir requestDisplayMode com mode "pip" não garante um PiP de verdade. A plataforma pode retornar outro modo (por exemplo, fullscreen) ou até se comportar de forma inesperada em versões antigas do SDK, portanto sempre verifique o resultado da promise e tenha um fallback.
Daí vem uma conclusão simples de UX: no PiP — apenas o mais importante. Timer, indicador de entrega, status da tarefa, botão “Expandir”. Nada de 12 checkboxes, tabelas com 10 colunas e “faça mais um café”.
8. GiftGenius + PiP: busca demorada e progresso em segundo plano
Voltemos ao GiftGenius. Imagine o cenário: o usuário passou pelo assistente em fullscreen, clicou em “Confirmar” e agora seu backend inicia uma seleção bem pesada — talvez, por um servidor MCP, você chame várias APIs externas, recalcule preços, aplique um monte de filtros. Isso pode levar, digamos, 10–20 segundos.
Do ponto de vista de UX, não queremos manter o usuário 20 segundos no fullscreen com um spinner girando. Melhor:
- Iniciar a seleção.
- Minimizar a interface para PiP, exibindo o progresso.
- Permitir que o usuário continue o chat (por exemplo, fazendo perguntas de esclarecimento).
- Ao finalizar — retornar o resultado em inline ou abrir um novo fullscreen com os presentes.
Vamos criar um hook simples para gerenciar esse comportamento:
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("Modo efetivo:", resultMode.mode);
await callTool("run_gift_job", payload);
setStatus("done");
await requestDisplayMode({ mode: "inline" });
};
return { status, startJob };
};
Agora, em ReviewStep, em vez de chamar callTool diretamente, usamos esse hook:
const ReviewStep: React.FC<StepProps> = ({ state }) => {
const { status, startJob } = useLongGiftJob();
return (
<div>
{/* ...resumo... */}
<button
disabled={status === "running"}
onClick={() => startJob(state)}
>
{status === "running" ? "Buscando presentes…" : "Iniciar seleção"}
</button>
</div>
);
};
Para que o status da tarefa em segundo plano esteja disponível tanto para o assistente em fullscreen quanto para a janela PiP, no código real faz sentido extrair useLongGiftJob para um contexto e lê-lo via useLongGiftJobContext. Vamos pular os detalhes de implementação do contexto (Provider, createContext): o importante é que o estado da job viva em um único lugar e diferentes camadas de UI apenas se inscrevam nele.
E um componente separado para a exibição no PiP:
const GiftPipView: React.FC<{ status: string }> = ({ status }) => {
return (
<div>
<p>GiftGenius está em execução…</p>
<p>Status: {status === "running" ? "em andamento" : "concluído"}</p>
<button
onClick={() => window.openai.requestDisplayMode({ mode: "fullscreen" })}
>
Expandir
</button>
</div>
);
};
No widget geral, vamos ajustar o render para levar em conta também o PiP:
const GiftGeniusWidget: React.FC = () => {
const mode = useDisplayMode();
const { status } = useLongGiftJobContext(); // via contexto, como discutido acima
if (mode === "pip") {
return <GiftPipView status={status} />;
}
if (mode === "fullscreen") {
return <GiftFullscreenWizard />;
}
return <InlineGiftPreview onExpand={/* como antes */} />;
};
Esse cenário combina muito bem com modos de voz (falaremos na aula sobre voice): por voz iniciamos a seleção, o PiP mostra o progresso, o chat fica abaixo e continua sua vida normalmente.
9. Vídeo + chat: quando fullscreen e PiP viram um media player
Historicamente, PiP está mais associado a vídeo que fica no canto da tela sobre o conteúdo. Por isso, faz sentido detalhar o cenário “vídeo + chat”. Aqui também não há mágica: na maioria dos casos, você simplesmente exibe o vídeo em fullscreen ou na janela PiP. A documentação da OpenAI cita explicitamente cenários de mídia como exemplo típico de uso de fullscreen e PiP.
O que isso pode significar para o GiftGenius? Por exemplo:
- você mostra um vídeo promocional do presente;
- um tutorial curto “como embrulhar um presente com estilo”;
- um review em vídeo de vários produtos.
No fullscreen, é possível renderizar um <video> completo com descrição e recomendações; no PiP — deixar apenas o player e, talvez, um pequeno título.
Um componente wrapper bem simples:
const GiftVideoPlayer: React.FC<{ src: string; title: string }> = ({
src,
title,
}) => (
<div>
<h3>{title}</h3>
<video
src={src}
controls
style={{ width: "100%", borderRadius: 8 }}
/>
</div>
);
No assistente em fullscreen, podemos oferecer ao usuário “Assistir ao review em vídeo deste presente” e, depois, minimizá-lo para PiP:
const WatchVideoStep: React.FC = () => {
const requestDisplayMode = useRequestDisplayMode();
return (
<div>
<GiftVideoPlayer src="/videos/gift-wrap.mp4" title="Como embrulhar um presente" />
<button
onClick={() => requestDisplayMode({ mode: "pip" })}
>
Manter o vídeo no canto e voltar ao chat
</button>
</div>
);
};
Algumas dicas práticas para cenários de mídia:
- não ative autoplay com som — é um antipadrão universal de UX;
- cuide das legendas e da possibilidade de pausar pelo teclado (espaço, setas);
- na janela PiP, não tente mostrar todo o texto associado, limite-se ao próprio vídeo.
10. Estado, remontagem do widget e particularidades em mobile
A pergunta mais desagradável que costuma aparecer neste ponto: “O estado do React vai ser preservado se eu alternar de inline para fullscreen e voltar?”
Resposta curta: não conte com isso.
Tecnicamente, o comportamento depende da versão do SDK e da implementação do host: em alguns casos, a transição entre modos acontece sem recriar o iframe; em outros, o widget é desmontado e montado novamente. A documentação ressalta que a preservação do contexto na troca de modos depende da implementação específica do SDK e de sua versão, e não é uma garantia para o desenvolvedor.
Abordagem prática:
- Todo estado crítico (passo do assistente, dados inseridos, identificador da tarefa em segundo plano) deve ser guardado:
- no backend (via seu servidor MCP e tokens de sessão),
- ou no contexto do ChatGPT (por exemplo, via tools que retornam “o estado atual do workflow”),
- ou em parâmetros de URL/armazenamento local, se houver base segura para isso.
- Use o estado do React como cache/camada de UI, mas esteja preparado para que ele possa zerar ao alternar de modo — então você o restaura a partir de uma fonte mais confiável.
A segunda sutileza diz respeito ao resultado de requestDisplayMode. Como já mencionado, uma solicitação com mode "pip" pode retornar como "fullscreen", especialmente no mobile, onde o PiP real pode não ser suportado ou ser automaticamente expandido para a tela cheia.
Um template típico:
const requestDisplayMode = useRequestDisplayMode();
const openPipSafe = async () => {
const result = await requestDisplayMode({ mode: "pip" });
if (result.mode !== "pip") {
// Fallback: por exemplo, mostrar uma mensagem ou adaptar o UI ao fullscreen
console.log("PiP indisponível, vamos operar no modo:", result.mode);
}
};
Assim você não acaba na situação de esperar uma janelinha e receber um UI em tela cheia com botões “específicos de PiP”. Nesse modo, essa interface vai parecer estranha.
Por fim, lembre-se de maxHeight e da rolagem interna: mesmo no fullscreen, o host pode limitar a altura do contêiner, e sua tarefa é organizar a rolagem para que não surjam três barras de rolagem aninhadas.
11. Erros comuns ao trabalhar com fullscreen e PiP
Erro nº 1: Fullscreen como modo padrão.
Alguns desenvolvedores veem “fullscreen” e já tentam transformar seu App em um SPA separado dentro do chat. O resultado: qualquer menção a presentes — e o usuário é jogado instantaneamente no assistente em tela cheia, mesmo querendo só algumas ideias. As diretrizes da OpenAI recomendam insistentemente começar com inline e só expandir para fullscreen quando houver necessidade objetiva.
Erro nº 2: PiP como um fullscreen em miniatura.
O PiP tem área muito limitada, mas às vezes tentam colocar tudo nele: abas, formulários, filtros. O usuário recebe uma interface microscópica, impossível de clicar. A abordagem certa é mostrar no PiP apenas o status e um ou dois botões-chave (por exemplo, “Expandir” e “Cancelar”).
Erro nº 3: Transições não explicadas entre os modos.
Quando o widget se expande de repente para fullscreen sem texto do GPT ou sem um clique explícito do usuário, isso desorienta. O mesmo vale para minimizar automaticamente para PiP ou retornar ao inline. Cada transição deve ser acompanhada por uma breve explicação na mensagem do modelo: “Agora vou abrir o assistente detalhado” antes do fullscreen; “Vou minimizar a seleção para uma janelinha enquanto ela é processada” antes do PiP.
Erro nº 4: Ignorar o mobile e as diferenças entre plataformas.
O desenvolvedor testa apenas no desktop, onde o PiP se comporta como esperado, e depois, no mobile, tudo vira fullscreen, o layout quebra e os botões ficam fora da safe-area. A documentação avisa claramente que o PiP no mobile pode ser implementado como fullscreen, e o comportamento pode mudar entre versões do SDK; portanto, testar nos dispositivos-alvo e lidar com requestDisplayMode com cuidado é obrigatório.
Erro nº 5: Confiança absoluta na preservação do estado ao trocar de modo.
Apoiar-se apenas no estado do React sem qualquer suporte de servidor/persistência leva a situações curiosas: o usuário passou duas etapas do assistente, clicou em “Minimizar para PiP” e, ao voltar, estava na primeira etapa com campos vazios. É melhor assumir que, ao trocar de modo, seu componente pode ser desmontado e projetar o gerenciamento de estado considerando esse risco.
Erro nº 6: Acessibilidade esquecida no assistente em fullscreen.
Um formulário bonito em tela grande nem sempre é confortável para pessoas com baixa visão ou que usam apenas o teclado. Texto muito pequeno, baixo contraste, botões “Avançar” e “Voltar” pouco legíveis — causas frequentes não só de UX ruim, mas também de problemas na revisão da Store. Vale verificar pelo menos o básico: contraste do texto, tamanho da fonte, navegação por Tab e rótulos textuais claros para os botões.
GO TO FULL VERSION