2. 既然有 inline,為什麼還需要 fullscreen?
在前一講關於 inline 的內容中我們已經約定:如果任務很短,能在 5–7 個物件或單一畫面內完成,inline 卡片就是理想做法。幾個禮物清單、少量篩選器、一兩個按鈕——這些都可以直接嵌在對話串中很好地運作。
但任何應用都會遇到「再多加一張卡片」也無法解決的時候:
- 需要收集很多參數(收禮者的個人檔案、配送限制、付款方式);
- 需要多步驟的精靈(wizard);
- 有大型表格、圖表、地圖或長篇說明。
在這裡,inline 會開始「難受」:寬度受限於聊天欄,垂直高度也有限、沒有導覽,而聊天本身只有一個滾動區。對於這類情境,Apps SDK 提供了 fullscreen 模式——一種「沉浸式」介面,讓你的元件能佔據大部分畫面,呈現複雜的版面配置。
今天的第二位主角是 PiP,也就是浮在聊天之上的小型視窗。它的典型角色包括:背景工作的狀態、迷你播放器、計時器、進度指示器。當某個耗時流程在「背景」進行而使用者仍要繼續與 GPT 對話時,PiP 再適合不過。
務必記住:fullscreen 和 PiP 都不是對 inline 的替代,而是加值層。先從 inline 開始;當 inline 空間不敷使用時再切到 fullscreen;當重點流程已啟動、只需要「隨時盯著」狀態時,再切到 PiP。
3. 技術基礎:displayMode 與模式切換
在 Apps SDK 看來,你的元件有一個目前的顯示狀態——displayMode。在本課撰寫時,主要有三種模式:"inline"、"fullscreen" 與 "pip"(picture-in-picture)。
主機(ChatGPT)會透過 window.openai 的全域資料以及 SDK 的特殊 hook 告知你的元件目前模式。在常見的 React 樣板中大致會是:
// 來自 Apps SDK 樣板的別名
const mode = useDisplayMode(); // 'inline' | 'fullscreen' | 'pip'
if (mode === "fullscreen") {
// 渲染我們的精靈(wizard)
} else {
// 渲染精簡的 inline UI
}
SDK 也提供 window.openai.requestDisplayMode({ mode }) 與/或 useRequestDisplayMode,讓你向主機請求切換模式。該方法會回傳一個含有實際設定模式的 Promise,因為平台可能會拒絕或調整你的請求(例如 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。
假設樣板裡有兩個 hook:
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,還會等待 Promise——如此一來之後就能對拒絕情形做出反應(例如如果因某些原因無法進入 fullscreen,就顯示訊息)。
InlineGiftPreview 本身相當簡單:
type InlineGiftPreviewProps = {
onExpand: () => void;
};
const InlineGiftPreview: React.FC<InlineGiftPreviewProps> = ({ onExpand }) => {
return (
<div>
<h3>禮物挑選</h3>
{/* …禮物卡片… */}
<button onClick={onExpand}>開啟詳細挑選</button>
<div>
);
};
到目前為止看起來很像「打開一個 modal」,但差別在於控制權不在你的 React,而是在 ChatGPT 主機應用;它可能會顯示標題、系統「返回」按鈕等等。
5. 設計 GiftGenius 的 fullscreen 精靈(wizard)
現在來設計禮物挑選的 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. fullscreen 精靈的 UX:進度、錯誤、返回
把表單全螢幕顯示只是成功的一半。使用者還需要:
- 清楚知道自己在第幾步;
- 能夠返回上一頁;
- 能看到長時間操作時正在發生什麼。
最簡單的 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>
);
};
在無障礙(a11y)方面請留意:
- 在 fullscreen 中,顯著的「下一步」、「返回」、「取消」按鈕應易於點擊;
- 文字對比需足夠;
- 可以用 Tab 依序走訪所有互動元素。
若可行,請為非標準控制元件(如自訂分類切換器)加上 aria-label。雖然本課不是 WCAG 考試,但基本的 a11y 注意事項能幫你在上架審查時少踩坑。
總結來看,fullscreen 精靈能解決複雜的多步驟流程:提供填表空間、進度與錯誤顯示。但應用的生命不止於此——很多任務會在「背景」繼續。這時就輪到第二種模式——PiP,接著我們來談。
7. 在 ChatGPT 世界中的 PiP,以及它為何「比較難搞」
我們已經了解如何用 fullscreen 處理複雜情境。現在看看相反的案例——當重要流程已啟動,只需「掌控進度」時,就該讓 PiP 出場。
在網頁世界裡,「picture-in-picture」通常讓人聯想到浮在角落的影片。在 ChatGPT 中,PiP 是小型浮動視窗,它會在滾動聊天時依然可見,能顯示狀態、進度或精簡的 UI。
根據文件與早期採用者的經驗,有幾點要注意:
- PiP 的空間極小。它不是放表單與複雜版面的地方,而是只放兩三個最重要的指標與一兩顆按鈕。
- 在桌面端,PiP 會「吸附」在上方且在任何滾動下皆可見;但在行動端,它常常會自動變成 fullscreen。
- 使用 requestDisplayMode 並把 mode 設為 "pip",不代表一定能拿到真正的 PiP。平台可能回傳其他模式(例如 fullscreen),或者在舊版 SDK 上有奇怪表現;因此一定要檢查 Promise 的結果並準備好 fallback。
因此很直觀的 UX 結論是:PiP 裡只放最重要的內容。計時器、配送指示器、任務狀態、一顆「展開」按鈕。不要塞 12 個核取方塊、10 欄的表格,或是「順便幫我煮咖啡」。
8. GiftGenius + PiP:耗時搜尋與背景進度
回到 GiftGenius。假設情境是:使用者完成 fullscreen 精靈並按下「確認」,接著你的後端啟動相當耗時的挑選流程——也許透過 MCP 伺服器呼叫多個外部 API、重新計算價格並套用一堆篩選。這可能需要 10–20 秒。
從 UX 角度,不希望讓使用者在 fullscreen 裡盯著轉圈圈 20 秒。更好的做法是:
- 開始挑選流程;
- 把介面縮到 PiP,顯示進度;
- 讓使用者能繼續聊天(例如提出補充問題);
- 完成後——把結果回傳到 inline,或開啟新的 fullscreen 顯示禮物。
我們寫個簡單的 hook 來管理這樣的行為:
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,而是用這個 hook:
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 放到 context,然後透過 useLongGiftJobContext 來取用。這裡略過 Provider 與 createContext 細節;重點是工作狀態集中於一處,不同 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(); // 透過 context,如上所述
if (mode === "pip") {
return <GiftPipView status={status} />;
}
if (mode === "fullscreen") {
return <GiftFullscreenWizard />;
}
return <InlineGiftPreview onExpand={/* 如前 */} />;
};
這種情境也很適合語音模式(在語音那一講會談):用語音啟動挑選,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. 狀態、元件重建與行動端特性
此時常見、但也最讓人頭痛的問題是:「如果我從 inline 切到 fullscreen 再切回來,React 狀態會保留嗎?」
簡短回答:不要指望。
技術上,行為取決於 SDK 版本與主機實作:有時模式切換不會重建 iframe;有時元件會被卸載後再掛載。文件也特別強調,切換模式時是否保留上下文取決於 SDK 與主機的具體實作與版本,對開發者來說不具保證。
實務做法:
- 把所有關鍵狀態(精靈步驟、已輸入資料、背景任務 ID)存放在:
- 後端(透過你的 MCP 伺服器與工作階段 token),
- 或 ChatGPT 上下文(例如讓工具回傳「目前 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 的情況——而你的介面上還擺著只適用於 PiP 的按鈕,顯得很突兀。
最後,記得 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」,回來時卻回到第一步、欄位全空。最好假設切換模式時你的元件可能被卸載,然後用這個風險來設計狀態管理。
錯誤 6:忘了 fullscreen 精靈的無障礙。
漂亮的大畫面表單,對視力較弱或只用鍵盤的人不一定友善。過小的字體、低對比、難以分辨的「下一步」「返回」按鈕——這些常見問題不只影響 UX,也會讓你在上架審查時吃虧。至少檢查基本項目:文字對比、字級、Tab 導覽是否可用,以及按鈕是否有清楚的文字標示。
GO TO FULL VERSION