CodeGym /コース /ChatGPT Apps /Fullscreen と PiP: ウィザード、複雑なコンテンツ、動画 + チャット

Fullscreen と PiP: ウィザード、複雑なコンテンツ、動画 + チャット

ChatGPT Apps
レベル 8 , レッスン 2
使用可能

2. inline があるのに、なぜ fullscreen が必要か?

前回の inline に関する講義で既に合意したとおり、タスクが短く、5–7 個の要素または 1 画面に収まるなら、inline カードが理想的です。複数のプレゼント候補のリスト、いくつかのフィルタ、1〜2 個のボタン — こうしたものはメッセージの流れの中にそのまま収まります。

しかし、どんなアプリでも「もう 1 枚カードを足す」だけでは救われない瞬間がやってきます:

  • 多くのパラメータを集める必要がある(受取人プロフィール、配送制限、支払い方法など);
  • 複数ステップのウィザードが必要;
  • 大きなテーブル、グラフ、地図、長文の説明がある。

この状況では inline は苦しくなります。幅はチャットのカラムで制限され、高さも限られる。ナビゲーションもなく、スクロールはチャットと共用です。まさにこうしたシナリオのために Apps SDK には fullscreen モード があります。これは没入型のインターフェースで、ウィジェットが画面の大部分を占有し、複雑なレイアウトを表示できます。

本日のもう一人の主役は PiP。チャットの上に重なる小さなフローティングウィンドウです。典型的な役割は、バックグラウンドタスクのステータス、ミニプレーヤー、タイマー、進捗インジケーター。長時間の処理が「裏で」進み、ユーザーは GPT との会話を続けたいときに最適です。

重要なのは、fullscreen も PiP も inline の代替ではなく、上位レイヤーだということ。まずは inline から始め、inline が手狭になったら fullscreen に移行する。すべての重要な処理が始まっており、ステータスを「視界に入れておく」だけでよいときは PiP に移る、という流れです。

3. 技術的基盤: displayMode とモード切り替え

Apps SDK の観点では、ウィジェットには現在の 表示状態 — すなわち displayMode があります。本講義執筆時点で、主要なモードは 3 つ: "inline""fullscreen""pip"(picture-in-picture)です。

ホスト(ChatGPT)は、window.openai のグローバルデータと SDK の専用フックを通じて、現在のモードをウィジェットに通知します。典型的な React テンプレートでは次のようになります:

// Apps SDK テンプレートのエイリアス
const mode = useDisplayMode(); // 'inline' | 'fullscreen' | 'pip'

if (mode === "fullscreen") {
  // ウィザードをレンダリング
} 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 に移行するための「詳細な選定を開く」ボタンを追加します。

テンプレートに次の 2 つのフックがあると仮定します:

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>
  );
};

見た目は「モーダルを開く」に似ていますが、制御しているのはあなたの React ではなく ChatGPT のホストアプリ側です。ヘッダーやシステムの「戻る」ボタンなどを表示する場合があります。

5. Fullscreen ウィザード GiftGenius を設計する

それでは、プレゼント選定の fullscreen ウィザードを設計しましょう。UX の観点では、プロセスをいくつかの論理的なステップに分割するのが賢明です。例えば:

  1. 受取人は誰か、どんな用途(イベント)か。
  2. 予算とギフトのタイプ(物理、体験、デジタル)。
  3. 確認と確定。

コードでは、ステップごとの簡単な状態マシンで表現できます:

type WizardStep = "recipient" | "preferences" | "review";

type WizardState = {
  step: WizardStep;
  recipient?: { ageRange: string; relation: string };
  preferences?: { budget: number; categories: string[] };
};

この状態を React に保持し、必要な画面をレンダリングする GiftFullscreenWizard コンポーネントを作成します。

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>
  );
};

2 つ目のステップで予算とカテゴリを集め、3 つ目で callTool / MCP ツールを呼び出します。これはすでにこれらのパラメータでギフトを選定できるもので、結果を表示します。

Fullscreen 画面では次のような要素のためのスペースがあります:

  • プログレスバーまたはステッパー;
  • より詳細なフィールドやヒント;
  • エラー状態(「問題が発生しました。もう一度お試しください」)。

UX ガイドラインからの推奨: 各ステップはできる限りシンプルにし、項目過多を避ける。1 つの巨大フォームよりも、3–4 の明快なステップが望ましいです。

6. Fullscreen ウィザードの UX: 進捗、エラー、戻る

フォームを全画面に出すだけでは半分です。ユーザーには次が必要です:

  • 自分がどのステップにいるかの把握;
  • 戻る手段;
  • 長い処理中に何が起きているかの可視化。

最も簡単なステッパーは純粋にビジュアルで実装できます:

const Stepper: React.FC<{ step: WizardStep }> = ({ step }) => {
  const index = step === "recipient" ? 1 : step === "preferences" ? 2 : 3;
  return <p>ステップ {index} / 3</p>;
};

そして単に各画面に Stepper を挿入します。より高度には、水平の「階段」UI を描画できますが、この講義ではコーディング学校は開きません。

重要な点はエラー処理です。たとえば最終ステップで 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 への配慮は、後の Store レビューで余計な痛みを避ける助けになります。

結果として、fullscreen ウィザードは複雑な多段シナリオを解決します。フォーム、進捗、エラーのためのスペースが確保できるからです。しかしアプリのライフはそこで終わりません。多くのタスクは「裏で」続きます。そこで 2 つ目のモード — PiP の出番です。

7. ChatGPT における PiP とは何か、そしてなぜ「気まぐれ」なのか

複雑なシナリオに fullscreen を使う方法はわかりました。次に、すべて重要な処理は開始済みで、あとは進捗を「見張る」だけという反対のケースを見てみましょう。ここで登場するのが PiP です。

Web の世界で「picture-in-picture」は通常、コンテンツの上に画面隅で再生される動画を連想します。ChatGPT の PiP は 小さなフローティングウィンドウのウィジェットで、チャットのスクロール中も視界に留まり、ステータスや進捗、コンパクトな UI を表示できます。

ドキュメントやアーリーアダプターの経験から知っておくべき重要な点がいくつかあります:

  1. PiP の領域はとても小さい。フォームや複雑なレイアウトの場所ではなく、2〜3 の主要な指標と 1〜2 個のボタン向けです。
  2. デスクトップでは PiP は上部に「張り付き」、どんなスクロールでも見え続けます。一方、モバイルでは多くの場合、自動的に fullscreen に変わります。
  3. requestDisplayModemode"pip" にしても、本物の PiP が保証されるわけではありません。プラットフォームは別のモード(例: fullscreen)を返すことがあり、古い SDK では挙動が不安定なことも。必ず Promise の結果を確認し、フォールバックを用意しましょう。

UX 上の結論はシンプルです。PiP には最重要な情報のみを。タイマー、配送インジケーター、タスクの状態、「展開」ボタンなど。12 個のチェックボックス、10 列のテーブル、「ついでにコーヒーも淹れて」みたいなものは禁物です。

8. GiftGenius + PiP: 長い検索とバックグラウンド進捗

GiftGenius に戻りましょう。シナリオを想定します。ユーザーは fullscreen ウィザードを完了し「確定」を押す。その後、あなたのバックエンドが重い選定処理を開始します — 例えば MCP サーバー経由で複数の外部 API を叩き、価格を再計算し、多数のフィルタを適用する。これには 10–20 秒かかるかもしれません。

UX 的には、20 秒間ユーザーを fullscreen のスピナーで拘束したくはありません。よりよいのは:

  1. 選定処理を開始する。
  2. インターフェースを PiP に畳み、進捗を見せる。
  3. ユーザーがチャットを続けられるようにする(例: 追加の質問)。
  4. 完了後 — inline に結果を返すか、ギフト一覧の新しい fullscreen を開く。

この挙動を管理する簡単なフックを作ります:

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 の代わりにこのフックを使います:

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 をコンテキストに切り出し、useLongGiftJobContext で読むのが良いでしょう。コンテキストの実装詳細(Provider、createContext)はここでは省略します。重要なのは、ジョブ状態を 1 箇所に持たせ、異なる 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(); // 前述のとおりコンテキスト経由

  if (mode === "pip") {
    return <GiftPipView status={status} />;
  }

  if (mode === "fullscreen") {
    return <GiftFullscreenWizard />;
  }

  return <InlineGiftPreview onExpand={/* これまで通り */} />;
};

このシナリオは音声モード(voice、別講義で扱います)とも相性が良いです。音声で選定を開始し、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 の state は保持されますか?」

短い答え: それを前提にしないでください

技術的には、挙動は SDK のバージョンやホスト実装に依存します。モード遷移で iframe の再作成なしに進む場合もあれば、ウィジェットがアンマウント・再マウントされる場合もあります。ドキュメントでも、モード変更時にコンテキストが保存されるかは SDK の実装とバージョンに依存し、開発者に対する保証ではないと明記されています。

実践的アプローチ:

  1. 重要な状態(ウィザードのステップ、入力済みデータ、バックグラウンドジョブ ID)は次のいずれかに保存:
    • バックエンド(あなたの MCP サーバーとセッショントークン経由)
    • ChatGPT コンテキスト(例: 「現在のワークフロー状態」を返す tools)
    • 安全に根拠がある場合は URL パラメータ / ローカルストレージ
  2. React state はキャッシュ / UI 層として使い、モード変更でクリアされうることを前提に — その際は、より信頼できるソースから復元する。

2 つ目の注意点は 結果としての requestDisplayMode です。前述のとおり、mode"pip" にしたリクエストは、特にモバイルで "fullscreen" として返ることがあります。PiP がサポートされていなかったり、自動的に全画面に展開されたりするためです。

典型的なパターン:

const requestDisplayMode = useRequestDisplayMode();

const openPipSafe = async () => {
  const result = await requestDisplayMode({ mode: "pip" });
  if (result.mode !== "pip") {
    // フォールバック: 例としてメッセージ表示や fullscreen 向けに UI を調整
    console.log("PiP は利用できません。モード:", result.mode);
  }
};

こうしておけば、小さなウィンドウを想定していたのに、PiP 専用のボタンが並ぶ UI を全画面で表示してしまう、といった奇妙な状況を避けられます。

最後に、maxHeight と内部スクロールに注意しましょう。fullscreen でもホストがコンテナの高さを制限する場合があります。スクロールが 3 重にネストされたりしないよう、適切に設計する必要があります。

11. Fullscreen と PiP でよくあるミス

誤り 1: Fullscreen をデフォルトにする。
「fullscreen」と聞いて、App をチャット内の独立した SPA にしようとしてしまう開発者がいます。その結果、ギフトに触れた途端、ユーザーはすぐ fullscreen ウィザードに飛ばされますが、欲しかったのはアイデアを少し見ることだけだったりします。OpenAI のガイドラインは、inline から始め、客観的に必要なときだけ fullscreen に広げることを強く推奨しています。

誤り 2: PiP を小さな fullscreen として扱う。
PiP の面積は非常に限られていますが、そこにタブやフォーム、フィルタなどすべてを詰め込みたくなることがあります。ユーザーは顕微鏡のような UI を渡され、クリックも難しくなります。正しいアプローチは、PiP にはステータスと 1〜2 個の主要ボタン(例: 「展開」「キャンセル」)だけを置くことです。

誤り 3: 説明のないモード遷移。
ウィジェットが GPT のテキストや明示的なクリックなしに突然 fullscreen へ展開すると、ユーザーは混乱します。PiP への自動縮小や inline への復帰も同様です。各遷移には、モデルの短いメッセージを添えましょう。fullscreen の前には「これから詳細ウィザードを開きます」、PiP の前には「計算中は小さなウィンドウに畳みます」など。

誤り 4: モバイルやプラットフォーム差の無視。
開発者がデスクトップでしかテストせず、そこでの PiP は期待通りに動いても、モバイルではすべて fullscreen になり、レイアウトが崩れ、ボタンが safe‑area の外に出てしまうことがあります。ドキュメントには、モバイルの PiP は fullscreen として実装される場合がある、また挙動は SDK バージョン間で変わりうると明記されています。対象デバイスでのテストと requestDisplayMode の丁寧な扱いは必須です。

誤り 5: モード変更時の状態保持を盲信する。
サーバー / 永続化の支えがないまま React state にのみ依存すると、笑えない事態が起きます。ユーザーがウィザードを 2 ステップ進め、「PiP に畳む」を押したのに、戻ってきたら最初のステップで空のフィールドに。モード変更時にコンポーネントがアンマウントされる可能性を前提にし、state マネジメントを設計しましょう。

誤り 6: Fullscreen ウィザードのアクセシビリティ軽視。
大画面の美しいフォームが、視力の弱い人やキーボードのみのユーザーにとって必ずしも使いやすいとは限りません。小さすぎる文字、低いコントラスト、読みにくい「次へ」「戻る」ボタンは、悪い UX だけでなく Store レビューでの指摘の原因にもなります。少なくとも基本事項(文字のコントラスト、フォントサイズ、Tab ナビゲーション、ボタンのわかりやすいラベル)は確認しましょう。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION