1. なぜ外観を管理するのか
いま皆さんのウィジェットは、おそらく「普通の React コンポーネント」のように見えるはずです。何らかの div、アイテムのリスト、ボタンがいくつか。通常の Web ならそれで十分なことが多いでしょう。しかし ChatGPT には注意点があります。あなたの UI はチャットの中に存在し、ユーザーにはすでにメッセージや他の Apps、音声インターフェースなどの視覚的コンテキストがあり、さらにコンテナのサイズ制約もあります。
覚えておくべき重要なポイントは2つあります。
第一に、ウィジェットには表示モード(displayMode)があります。inline、fullscreen、場合によっては PiP。モードによって、利用できる面積、スクロールの挙動、ユーザーの期待が変わります。
第二に、プラットフォームはウィジェットに高さの上限(maxHeight)とテーマ(theme)を伝えます。これらを無視して、ひとつのメッセージの中に Notion 並みの巨大なものを描画すると、チャットは「ブラックホール」になってしまい、巨大な iframe にすべてが沈み込みます。OpenAI は UI を簡潔にし、システムの色やタイポグラフィを尊重することを明確に推奨しています。
GiftGenius の典型的なシナリオは、これが実際にどう機能するかをよく示しています。ユーザーが「友達へのギフトを $50 以内で選んで」と依頼すると、ChatGPT は GiftGenius を起動し、inline モードでギフトのコンパクトなカードといくつかのボタンを表示します。ユーザーが「詳しく」をクリックすると、ウィジェットは fullscreen を要求し、そこでフィルターや詳細説明、レビューを表示します。購入手続き中は「注文を処理中…」の小さな PiP/モーダルでステータスを見せ、チャット全体を覆わないようにできます。
この講義の目標は次のとおりです。
- 現在の displayMode を理解し、それに適切に対応する;
- 必要に応じてモードを切り替える(inline ↔ fullscreen、場合によっては PiP);
- maxHeight を尊重し、「二重スクロール」を発生させない;
- ライト/ダークのテーマや画面幅に合わせてスタイルを適応させる;
- ChatGPT の中で「ネイティブ」らしく見える layout を構築する。
2. displayMode のモード: inline, fullscreen, PiP
まず概念から。displayMode は、ChatGPT 内のあなたのウィジェットのコンテナ状態です。これはプラットフォームから(window.openai.displayMode またはフック useDisplayMode 経由で)渡され、"inline"、"fullscreen"、"pip" のような値をとります。
Inline
Inline はデフォルトのモードです。ウィジェットはテキスト回答の間にもうひとつの「ブロック」としてメッセージの流れに直接差し込まれます。幅はチャット列の幅に制限されます(デスクトップでは約700〜800px、モバイルでは画面幅)。高さは動的ですが無限ではありません。
Inline が最適なのは次のような場合です。
- 短く自己完結した表示:ギフトカード、オプション一覧、検索サマリーなど;
- 1〜2 個のアクション:「選択」「キャンセル」「さらに表示」。
GiftGenius ではこれが主なモードです。ユーザーがリクエストを書いたら、3〜5 枚のギフトカードとボタンを表示し、画面全体を占有しません。
Fullscreen (Canvas)
Fullscreen(または canvas)は、ウィジェットが可視領域の大部分を占めるモードです。チャット自体は消えません。入力欄は残りますが、主な注目はあなたの UI に移ります。
fullscreen を有効にするのが妥当なのは、次のようなときです。
- 入力フィールドが多い、または複雑なウィザード(注文手続き、詳細なフィルター、設定);
- 大きなテーブル、地図、数十件の比較を見せたい;
- inline に収まらず、700px 高のミニ Excel のように見え始めたとき。
GiftGenius では、フィルター、ソート、詳細説明、複数のタブなどをしっかり提供するために fullscreen が必要になります。
PiP / モーダル
PiP(picture-in-picture)とモーダルは、主要コンテンツの上に重ねる小さな「フローティング」ウィンドウです。現状の Apps SDK 実装では、PiP は displayMode の特別なモードとして、または requestModal() によるモーダルで実装されることが多いです。
役に立つのは次のようなときです。
- 長い処理のステータスを見せたい(注文処理、動画レンダリングなど);
- メインの流れを中断せずにちょっとした質問をしたい(簡単な確認など);
- ユーザーに「ウィジェットを見ながら」チャットを続ける選択肢を与えたい。
GiftGenius では「注文を処理中… 30%」という小さなパネルと「キャンセル」ボタンのような形が考えられます。
簡単な比較
見比べやすい表:
| モード | 配置位置 | 典型的なユースケース | 制約 |
|---|---|---|---|
|
メッセージフロー内 | リスト、カード、1〜2個のボタン | 高さに制限、幅が狭い |
|
チャットの上部 / サイド | ウィザード、複雑なフォーム、テーブル | 適切なレイアウトとナビゲーションが必要 |
| PiP / modal | フローティングレイヤー | ステータス、ミニフォーム、動画 | 領域が非常に狭い。要素は大きく簡潔に |
fullscreen を「本物のアプリ」、inline を「プレビュー」と捉えないことが重要です。どちらも同じ App が、ただ「姿勢」を変えているだけです。
3. モード操作のためのフック: useDisplayMode、useRequestDisplayMode、useRequestModal
UX の観点で inline/fullscreen/PiP が何かを理解したところで、次は Apps SDK のフックを使ってコードからそれらを扱う方法を見ていきましょう。
window.openai.displayMode を直接読む代わりに、テンプレート由来のフックを使います。これは変更に購読し、SDK のイベント処理という儀式からあなたを解放してくれます。典型的なインターフェースは次のとおりです。
// 疑似的な型です。実際の名前はテンプレートを参照してください
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
function useDisplayMode() {
// 現在のモードを返す
return { displayMode: 'inline' as DisplayMode };
}
function useRequestDisplayMode() {
// モード変更を要求する関数
return {
requestDisplayMode: (mode: DisplayMode) => {
/* window.openai.requestDisplayMode を呼び出す */
},
};
}
現在のモードを表示し、「展開 / 折りたたみ」を行うボタンを持つシンプルなコンポーネントを作ってみましょう。
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>モード: {displayMode}</span>
<button onClick={toggle} className="underline">
切り替え
</button>
</div>
);
}
本番の App ではこのような「デバッグ用」要素は普通隠しますが、Dev Mode では、切り替え時のウィジェットの挙動を体感するのに非常に役立ちます。
Inline と Fullscreen を別コンポーネントで
ありがちなミスは、同じレイアウトで全モードを捌こうとして、JSX に if(displayMode === ...)を大量に書き込むことです。頭をスッキリさせるには、表示を分割する方が快適です。
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 />;
}
このように、「fullscreen なら複雑なウィザード、そうでなければコンパクトな inline」とコードが自然に読めます。各サブコンポーネントは、その制約に合わせて個別にスタイルできます。このアプローチは本モジュールの推奨そのものです。巨大な if/else で1コンポーネントに詰め込むのではなく、モードごとに分離しましょう。
モーダル: useRequestModal
テンプレートに useRequestModal がある場合、インターフェースはだいたい次のようになります。
const { requestModal } = useRequestModal();
// requestModal({ title }) など
モーダルは fullscreen に似ていますが、置き換えにはなりません。fullscreen は大きなシナリオ向け、モーダルは短い1ステップ(操作の確認、クーポンコード入力など)向けです。
4. サイズ制御:maxHeight、スクロール、notifyIntrinsicHeight()
二つ目の重要な軸は高さです。プラットフォームはウィジェットに「利用可能な最大高さ」を伝えます。この上限は window.openai.maxHeight やフック useMaxHeight で読めます。
なぜ単に「height: 5000px」としてはいけないのか
maxHeight を無視して巨大な固定高さを設定すると、ChatGPT はあなたのコンテンツを切り取らざるを得ません。あるいはユーザーに二重スクロールを強いることになります。外側はチャット、内側はあなたのウィジェット。どちらをスクロールすれば目的のボタンに届くのか当てる必要があり、不快な UX です。
正しい戦略は次のとおりです。
- maxHeight の上限を読む。
- 特に inline では、メインのスクロールはチャット側に残すようレイアウトする。
- fullscreen では必要に応じて内側スクロールを用いてもよいが、慎重に。
useMaxHeight とコンテナの制限
ルートコンテナに最大高さを設定するシンプルなラッパーを書いてみましょう。
import { useMaxHeight } from '@/apps-sdk';
export function WidgetContainer(props: { children: React.ReactNode }) {
const { maxHeight } = useMaxHeight(); // 例えば 600
return (
<div
style={{ maxHeight }}
className="overflow-y-auto p-4 bg-background border border-border rounded-xl"
>
{props.children}
</div>
);
}
ここでは正直に高さを制限し、コンテナ内の縦スクロールを有効にしていますが、常識的な範囲に留めています。実際には inline では過度な内側スクロールは避け、膨大なリストの代わりに一部のみを表示して「さらに表示」ボタンを置くか、fullscreen を提案するのがよいでしょう。
動的な高さと notifyIntrinsicHeight()
もうひとつの注意点として、コンテンツのサイズは時間と共に変化します。例えば、最初は「ギフトを読み込み中…」のスピナーを表示し、その後 10 枚のカードのリスト、さらにユーザーがフィルターを開閉する、といった具合です。ChatGPT がウィジェットのために適切なスペースを確保し、切り取りを防ぐには、高さが変わるたびにホストへ新しい値を通知する必要があります。これに使うのが notifyIntrinsicHeight() です。
テンプレートでは、useAutoResize のようなフックでラップされていることが多いです。実装は次のようにできます。
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;
}
使い方:
export function GiftListInline() {
const containerRef = useAutoResize();
return (
<div ref={containerRef}>
{/* コンテンツ */}
</div>
);
}
考え方はシンプルです。ルートの div の高さが変わったら SDK の API を呼び、ChatGPT にコンテナを合わせてもらう。これは経験豊富な開発者が勧める定石パターンで、コンテンツ全体を包む「オートリサイズ用ラッパー」を置きます。
簡単な図
フローチャートで表すと:
flowchart TD
A[ウィジェットのコンテンツが変化] --> B[ResizeObserver が新しい高さを検知]
B --> C["notifyIntrinsicHeight(newHeight) を呼び出す"]
C --> D[ChatGPT がコンテナを拡大/縮小]
D --> E[ユーザーは切り取りのない整ったスクロールを体験]
これでサイズと高さの方針は明確です。ウィジェットが割り当て領域からはみ出したり、ユーザーに二重スクロールの「謎解き」を強いたりしてはいけません。
5. テーマ(theme)、色、ボーダー:ウィジェットを「ネイティブ」らしく
displayMode と maxHeight が利用可能な「量」を決めるのに対し、テーマ(theme)とパレットは、チャット内で UI が「どう見えるか」を決めます。
ChatGPT は少なくともライトとダークのテーマをサポートしています。プラットフォームはこれを window.openai.theme や _meta["openai/theme"] でウィジェットに渡し、React テンプレートには useOpenAiGlobal("theme") や useTheme のようなフックがあります。
要点はひとつ。あなたの UI はテーマに合わせて適応すべきで、独自テーマを押し付けるべきではありません。
テーマの取得
シンプルなフック例:
import { useOpenAiGlobal } from '@/apps-sdk';
export function useThemeMode() {
const theme = useOpenAiGlobal<'light' | 'dark'>('theme') ?? 'light';
return { theme };
}
コンポーネント側:
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>
);
}
実案件では、darkMode: 'class' を有効にした Tailwind を使い、ウィジェットのルートに dark クラスを付けることが多いでしょう。いずれにせよ、本質は同じです。テーマは Apps SDK から来るのであって、勝手に存在するものではありません。
色、ボーダー、タイポグラフィ
OpenAI のガイドに従いましょう。
- システムフォントと丁寧なタイポグラフィを使う;
- システムの色を強引に上書きしない;
- ウィジェットはチャットの「ネイティブ」な要素であるべきで、ド派手なグラデーションの独立したランディングのようにしない。
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">
選択
</button>
</div>
);
}
ここでは bg-background、border-border、text-foreground、bg-primary などが ChatGPT のテーマに紐付けられた CSS 変数/ユーティリティクラスであることを想定しています。このアプローチは推奨事項にも記載されており、ハードコードではなくテーマに結びついた変数やクラスを使います。
6. レイアウトとレスポンシブ対応:desktop、mobile、PiP
三つ目の軸は幅とデバイスです。大雑把に言えば、ウィジェットの外観はモード(displayMode)、利用可能な高さ(maxHeight)、そして利用可能な幅(desktop/mobile/PiP)で決まります。
このセクションでは三つ目のパラメータを扱います。デスクトップの inline ウィジェットとモバイルのそれは幅が異なります。PiP ではさらに狭くなります。Apps SDK は userAgent、safeArea、場合によってはコンテナサイズなどのシグナルを提供し、useOpenAiGlobal で読めます。
基本原則
重要な原則をいくつか。
第一に、固定幅に依存しないこと。ユーザーの画面は狭い(スマホ)場合も広い(大きなデスクトップ)場合もあります。したがってレイアウトは、固定の width: 400px ではなく、auto-fit の flex/grid を基盤にするとよいです。
第二に、横スクロールは避けること。テーブルやカードが収まらないなら、fullscreen に切り替えるか短縮版を見せるのが良いでしょう。カルーセルを使う選択肢もあります。
第三に、PiP/モーダルは非常に狭いことを忘れずに。そこへ大きなフォームを詰め込むべきではありません。ユーザーは入力欄に物理的に触りづらくなります。
これらはドキュメントでも強調されています。レスポンシブ対応、safeArea、desktop と mobile の違い、そして過密レイアウトの危険性です。
Inline と Fullscreen でレイアウトを分ける
GiftGenius に戻りましょう。inline と fullscreen のギフト一覧は大きく見た目が異なってよいのです。2 つのコンポーネントを用意しましょう。
コンパクトな inline:最大 3 枚のカード。モバイルでは 1 カラム、広い画面では 2 カラム。
export function GiftListInline() {
const gifts = useGiftData(); // 仮のフック。toolOutput から取得する想定
return (
<WidgetContainer>
<h2 className="text-base font-semibold mb-3">
ギフトのセレクション
</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">
最初の 3 件を表示しています。すべてを見るにはウィジェットを展開してください。
</p>
)}
</WidgetContainer>
);
}
fullscreen 版:グリッド、フィルター、より多くのカード。
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">
あなたへのギフト
</h1>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="名前でフィルター"
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>
);
}
ここでは fullscreen コンテンツに内側の縦スクロール(main で overflow-y-auto)を許容しています。これは fullscreen モードでは妥当です。一方で inline 版は、ガイドが推奨するように、コンパクトで「2 秒で読める」レベルに保ちます。
Schematic: モードごとの挙動
仕上げにシンプルな図を。
stateDiagram-v2
[*] --> Inline
Inline: カード3枚、テキスト最小限
Inline --> Fullscreen: 「展開」/「すべて表示」をクリック
Fullscreen: グリッド、フィルター、データ多め
Fullscreen --> Inline: 「閉じる」ボタン / ホストの操作
Fullscreen --> PiP: 長時間の処理、進捗を表示
PiP: 小さなステータスパネル
PiP --> Inline: 処理完了、最終メッセージを表示
この流れは、UX パターンと非常に近いものです。inline はティーザー、fullscreen は作業用ツール、PiP は進捗インジケーター。
7. 実践: 同一ウィジェットの 2 つのモード
ここまでをコードで定着させましょう。この講義の演習として、現在の学習用アプリに対して 2 ステップを実装するとよいでしょう。
ステップ 1. カード付きの Inline ウィジェット
既存の GiftGenius を拡張し、inline モードで次を実現してください。
- 見出し「ギフトのセレクション」を表示する;
- toolOutput から最大 3 枚のギフトカードを表示する;
- ギフトが 3 件を超える場合、「すべてを見るにはウィジェットを展開してください」というヒントを表示する;
- useAutoResize と notifyIntrinsicHeight() で高さを丁寧に同期する。
スタイルはテーマに基づくべきです。ハードコードした色ではなく、theme に紐づいたクラスや変数を使ってください。
ステップ 2. フォーム付き Fullscreen バージョン
次に fullscreen 表示を追加し、以下を実現します。
- 見出し + 名前による検索欄を表示する;
- すべてのギフトをグリッドで表示する;
- メイン領域内の縦スクロールを許可する;
- 「会話に戻る」ボタンを提供する(requestDisplayMode('inline') を呼び出す)。
コンポジション例は次のとおりです。
export function GiftGeniusWidget() {
const { displayMode } = useDisplayMode();
return (
<>
<DisplayModeDebug />
{displayMode === 'fullscreen' ? (
<GiftListFullscreen />
) : (
<GiftListInline />
)}
</>
);
}
ChatGPT の Dev Mode では手動でモードを切り替えられますし、inline 版の「すべて表示」ボタンのクリックでプログラム的に fullscreen を要求することもできます(useRequestDisplayMode 経由)。同一の App が、displayMode に応じて見た目と振る舞いをどう変えるか、理解が深まるでしょう。
8. 外観管理に関する典型的な落とし穴
先へ進む前に、displayMode、サイズ、テーマ、レイアウトに関する典型的な落とし穴をいくつか固定しておきましょう。最初から避けられれば、Apps SDK での開発はずっと快適になります。
エラー №1: displayMode を無視し、すべてを強引に fullscreen 風にする。
重いレイアウト(ほぼ独立した SPA)を 1 つだけ作って、それを無理やり inline に押し込む開発者がときどきいます。結果として、ユーザーはスクロールだらけのミニ Notion を目にします。正しいアプローチは、モードごとに別の表示を設計し、inline はコンパクトな「1 画面」形式であることを尊重することです。
エラー №2: 巨大な固定高さと二重スクロール。
height: 800px のように固定し、maxHeight を忘れるのは、ウィジェットが切り取られるか、内外でスクロールが同時発生する近道です。ユーザーは「正しいスクロールバー」を探す羽目になり、UX を大きく損ねます。代わりに maxHeight を読み、max-height で制限し、高さの変化は notifyIntrinsicHeight() で通知しましょう。
エラー №3: テーマを無視し、ブランド色で全面的に塗り替える。
フォントや背景、強烈なグラデーションを自前で当て、ChatGPT のライト/ダークテーマを完全に無視すると、プラットフォームの視覚的一貫性を壊します。ガイドは明確です。システムの色とフォントを使い、ブランドは控えめなアクセント(ボタン、アイコン、ロゴ)で添える程度に。フック経由で theme を監視し、パレットを合わせましょう。
エラー №4: PiP/モーダルに複雑すぎる UI を入れる。
入力欄多数のフォームを小さな PiP に詰め込むのは無理筋です。そこに相応しいのは、ごく簡単なケースだけです。処理の進捗、1〜2 個のボタン、1 つの入力欄。その他は fullscreen が相応です。
エラー №5: 800px 前提の固定レイアウト、モバイル未テスト。
「とりあえず 800px に合わせれば、そのうちスマホでも何とかなるだろう」という設計。実際には、ChatGPT のモバイルクライアントは幅も挙動も大きく異なり、PiP はさらに狭い。userAgent/safeArea を忘れず、grid/flex を固定幅なしで使い、少なくとも一度は狭いレイアウトで確認しましょう。
エラー №6: フックを使わずに window.openai を直接扱う。
形式上は、const mode = window.openai.displayMode のように書けますが、その場合はイベント購読、React の更新、SDK の変更による不具合対応を自分で抱えることになります。フック(useDisplayMode、useMaxHeight、useOpenAiGlobal、useRequestDisplayMode)は、このルーチンを隠し、コードをクリーンに保つために用意されています。素直に使った方が平和です。
GO TO FULL VERSION