CodeGym /Các khóa học /ChatGPT Apps /Fullscreen và PiP: wizard, nội dung phức tạp, video + cha...

Fullscreen và PiP: wizard, nội dung phức tạp, video + chat

ChatGPT Apps
Mức độ , Bài học
Có sẵn

2. Tại sao cần fullscreen khi đã có inline?

Trong bài trước về inline, ta đã thống nhất: nếu nhiệm vụ ngắn và gói gọn trong 5–7 đối tượng hoặc một màn hình, thẻ inline là lựa chọn lý tưởng. Danh sách vài món quà, vài bộ lọc, một hai nút — tất cả đều hoạt động tốt ngay trong luồng hội thoại.

Nhưng sẽ đến lúc “thêm một thẻ nữa” không còn cứu được tình hình:

  • cần thu thập nhiều tham số (hồ sơ người nhận, ràng buộc giao hàng, phương thức thanh toán);
  • cần một wizard nhiều bước;
  • có bảng lớn, biểu đồ, bản đồ, mô tả dài.

Lúc này inline bắt đầu “bí bách”: chiều rộng bị giới hạn bởi cột chat, chiều cao cũng vậy, không có điều hướng, và chat chỉ có một cuộn. Chính cho các kịch bản như vậy, Apps SDK có chế độ fullscreen — một giao diện “đắm chìm”, nơi widget của bạn chiếm phần lớn màn hình và có thể hiển thị layout phức tạp.

Nhân vật thứ hai hôm nay — PiP, cửa sổ nổi nhỏ nằm trên chat. Vai trò điển hình: trạng thái tác vụ nền, mini‑player, hẹn giờ, chỉ báo tiến độ. PiP lý tưởng khi thứ gì đó chạy “nền” còn người dùng vẫn tiếp tục trò chuyện với GPT.

Lưu ý: cả fullscreen và PiP không phải để thay thế inline mà là lớp bổ sung. Bắt đầu với inline, chuyển sang fullscreen khi inline trở nên chật chội; chuyển sang PiP khi mọi thứ quan trọng đã chạy và chỉ cần “để mắt” đến trạng thái.

3. Nền tảng kỹ thuật: displayMode và chuyển đổi chế độ

Về phía Apps SDK, widget của bạn có trạng thái hiển thị hiện tại — displayMode. Tại thời điểm viết khóa học có ba chế độ chính: "inline", "fullscreen""pip" (picture-in-picture).

Host (ChatGPT) cung cấp cho widget của bạn chế độ hiện tại qua dữ liệu toàn cục trong window.openai và các hook đặc biệt từ SDK. Trong mẫu React điển hình sẽ có thứ gì đó như:

// bí danh từ mẫu Apps SDK
const mode = useDisplayMode(); // 'inline' | 'fullscreen' | 'pip'

if (mode === "fullscreen") {
  // render wizard của chúng ta
} else {
  // render UI inline gọn nhẹ
}

SDK cũng cung cấp phương thức window.openai.requestDisplayMode({ mode }) và/hoặc hook useRequestDisplayMode để yêu cầu host chuyển chế độ. Phương thức này trả về một promise với chế độ thực tế được áp dụng, vì nền tảng có thể từ chối hoặc điều chỉnh yêu cầu của bạn (ví dụ, trên di động PiP gần như luôn bị chuyển thành fullscreen).

Sơ đồ vòng đời các chế độ có thể hình dung như sau:

stateDiagram-v2
    [*] --> Inline
    Inline --> Fullscreen: requestDisplayMode('fullscreen')
    Fullscreen --> Inline: requestDisplayMode('inline') / nút "Quay lại"
    Fullscreen --> PiP: requestDisplayMode('pip')
    PiP --> Fullscreen: "Mở rộng"
    PiP --> Inline: hoàn tất tác vụ

Tên gọi và tập chế độ chính xác có thể thay đổi theo phiên bản SDK, vì vậy khi lên production luôn nên kiểm tra lại tài liệu thay vì dựa vào “như trong khóa học”.

4. Lần chuyển đầu tiên: làm nút “Mở rộng sang toàn màn hình”

Bắt đầu từ cái nhỏ: lấy widget inline hiện có của chúng ta — GiftGenius, App học tập từ các mô-đun trước đang hiển thị 3–5 thẻ quà tặng — và thêm nút “Mở lựa chọn chi tiết” để chuyển sang fullscreen.

Giả sử trong mẫu có hai 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" });
      }}
    />
  );
};

Ở đây InlineGiftPreview là UI inline hiện tại của chúng ta, còn GiftFullscreenWizard là component wizard mới mà chúng ta sắp thiết kế. Trong handler onExpand chúng ta không chỉ gọi requestDisplayMode mà còn đợi promise — để sau này có thể phản ứng khi bị từ chối (ví dụ, hiển thị thông báo nếu vì lý do nào đó không thể bật fullscreen).

Bản thân InlineGiftPreview khá đơn giản:

type InlineGiftPreviewProps = {
  onExpand: () => void;
};

const InlineGiftPreview: React.FC<InlineGiftPreviewProps> = ({ onExpand }) => {
  return (
    <div>
      <h3>Gợi ý quà tặng</h3>
      {/* ...các thẻ quà tặng... */}
      <button onClick={onExpand}>Mở lựa chọn chi tiết</button>
    </div>
  );
};

Trông rất giống “mở modal”, nhưng khác biệt ở chỗ việc này không do React của bạn kiểm soát mà do ứng dụng host ChatGPT, và nó có thể hiển thị tiêu đề, nút hệ thống “Quay lại”, v.v.

5. Thiết kế wizard fullscreen cho GiftGenius

Giờ hãy thiết kế wizard fullscreen để chọn quà. Về UX, hợp lý là chia quy trình thành vài bước logic. Ví dụ:

  1. Người nhận là ai và dịp gì.
  2. Ngân sách và loại quà (vật lý, trải nghiệm, kỹ thuật số).
  3. Kiểm tra và xác nhận lựa chọn.

Trong code, có thể biểu diễn bằng một máy trạng thái đơn giản theo các bước:

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

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

Tạo component GiftFullscreenWizard, lưu trạng thái này trong React và render màn phù hợp.

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

Mỗi bước là một component nhỏ có form. Ví dụ, bước đầu:

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>Chúng ta chọn quà cho ai?</h2>
      <input
        placeholder="Người đó là gì với bạn?"
        value={relation}
        onChange={(e) => setRelation(e.target.value)}
      />
      <input
        placeholder="Tuổi (ví dụ: 25–34)"
        value={ageRange}
        onChange={(e) => setAgeRange(e.target.value)}
      />
      <button
        onClick={() =>
          onNext({
            recipient: { relation, ageRange },
            step: "preferences",
          })
        }
      >
        Tiếp theo
      </button>
    </div>
  );
};

Ở bước thứ hai ta thu thập ngân sách và danh mục; bước thứ ba — gọi callTool / công cụ MCP đã biết cách chọn quà theo các tham số này và hiển thị kết quả.

Quan trọng là trên màn fullscreen chúng ta có chỗ cho:

  • progress bar hoặc stepper;
  • các trường và gợi ý chi tiết hơn;
  • các trạng thái lỗi (“đã có lỗi xảy ra, vui lòng thử lại”).

Khuyến nghị từ UX guidelines: mỗi bước nên giữ tối giản, không nhồi nhét quá nhiều trường; tốt hơn là 3–4 bước rõ ràng thay vì một biểu mẫu “quái vật”.

6. UX của wizard fullscreen: tiến độ, lỗi, quay lại

Chỉ hiển thị form toàn màn hình mới là nửa công việc. Người dùng cần:

  • hiểu mình đang ở bước nào;
  • có khả năng quay lại;
  • thấy được chuyện gì đang diễn ra khi thao tác lâu.

Stepper đơn giản có thể làm thuần thị giác:

const Stepper: React.FC<{ step: WizardStep }> = ({ step }) => {
  const index = step === "recipient" ? 1 : step === "preferences" ? 2 : 3;
  return <p>Bước {index} trong 3</p>;
};

Và chỉ cần chèn Stepper vào mỗi màn. Bản nâng cao — render “bậc thang” ngang các bước, nhưng trong phạm vi khóa học ta không đi sâu vào dàn trang.

Điểm quan trọng — xử lý lỗi. Giả sử ở bước cuối ta gọi công cụ 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,
      });
      // Kết quả sẽ xuất hiện trong chat / widget
    } catch (e) {
      setError("Không thể chọn quà, vui lòng thử lại.");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {/* hiển thị tóm tắt tham số */}
      {error && <p style={{ color: "red" }}>{error}</p>}
      <button disabled={loading} onClick={handleConfirm}>
        {loading ? "Đang chọn…" : "Xác nhận và bắt đầu chọn"}
      </button>
    </div>
  );
};

Về khả năng truy cập, cần đảm bảo:

  • trong fullscreen, các nút “Tiếp theo”, “Quay lại” và “Hủy” đủ lớn, dễ bấm;
  • văn bản có độ tương phản hợp lý;
  • có thể đi qua mọi phần tử tương tác theo thứ tự bằng phím Tab.

Nếu có thể — nên thêm aria-label cho các control phi tiêu chuẩn (ví dụ, công tắc danh mục tùy biến). Dù khóa học không phải kỳ thi WCAG, chú ý tối thiểu đến a11y sẽ giúp bạn vượt review trên Store dễ dàng hơn.

Kết quả, wizard fullscreen giải quyết các kịch bản nhiều bước phức tạp: cung cấp chỗ cho form, tiến độ và lỗi. Nhưng vòng đời ứng dụng chưa dừng ở đó — nhiều tác vụ vẫn tiếp tục “chạy nền”. Khi đó ta có chế độ thứ hai — PiP, sẽ bàn ngay sau đây.

7. PiP trong thế giới ChatGPT là gì và vì sao “khó tính”

Chúng ta đã rõ cách dùng fullscreen cho kịch bản phức tạp. Giờ xem tình huống ngược lại — khi mọi thứ quan trọng đã khởi chạy và chỉ cần “kiểm soát” tiến độ. Lúc này PiP phát huy tác dụng.

Trong web, “picture-in-picture” thường gắn với video nằm ở góc màn hình phía trên nội dung. Trong ChatGPT, PiP là cửa sổ nổi nhỏ của widget, luôn nhìn thấy khi cuộn chat và có thể hiển thị trạng thái, tiến độ hoặc UI gọn nhẹ.

Một vài đặc điểm quan trọng từ tài liệu và kinh nghiệm của những early adopters:

  1. PiP có rất ít diện tích. Đây không phải chỗ cho form hay layout phức tạp, mà là nơi cho hai ba chỉ số chính và một hai nút.
  2. Trên desktop, PiP “dính” ở phía trên và luôn hiển thị khi cuộn; trên di động, nó thường tự động chuyển thành fullscreen.
  3. Yêu cầu requestDisplayMode với mode "pip" không đảm bảo có PiP thật. Nền tảng có thể trả về chế độ khác (ví dụ, fullscreen) hoặc hành xử kỳ lạ trên SDK cũ; vì vậy luôn kiểm tra kết quả promise và chuẩn bị fallback.

Từ đó rút ra kết luận UX đơn giản: PiP chỉ dành cho thứ quan trọng nhất. Hẹn giờ, chỉ báo giao hàng, trạng thái tác vụ, nút “Mở rộng”. Đừng nhét 12 checkbox, bảng 10 cột hay “pha cho tôi thêm cà phê”.

8. GiftGenius + PiP: tìm kiếm dài và tiến độ nền

Quay lại GiftGenius. Kịch bản: người dùng đi qua wizard fullscreen, nhấn “Xác nhận”, backend của bạn khởi chạy thuật toán chọn quà khá nặng — có thể qua MCP server gọi nhiều API ngoài, tính lại giá, áp hàng đống bộ lọc. Việc này có thể mất 10–20 giây.

Về UX, không nên giữ người dùng 20 giây trong fullscreen với spinner quay. Tốt hơn là:

  1. Khởi chạy việc chọn quà.
  2. Thu nhỏ giao diện vào PiP, hiển thị tiến độ.
  3. Cho phép người dùng tiếp tục chat (ví dụ, hỏi thêm).
  4. Sau khi xong — trả kết quả inline hoặc mở fullscreen mới với các món quà.

Tạo một hook đơn giản để điều khiển hành vi này:

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("Chế độ thực tế:", resultMode.mode);

    await callTool("run_gift_job", payload);
    setStatus("done");
    await requestDisplayMode({ mode: "inline" });
  };

  return { status, startJob };
};

Giờ trong ReviewStep thay vì gọi trực tiếp callTool ta dùng hook này:

const ReviewStep: React.FC<StepProps> = ({ state }) => {
  const { status, startJob } = useLongGiftJob();

  return (
    <div>
      {/* ...tóm tắt... */}
      <button
        disabled={status === "running"}
        onClick={() => startJob(state)}
      >
        {status === "running" ? "Đang chọn quà…" : "Bắt đầu chọn"}
      </button>
    </div>
  );
};

Để trạng thái tác vụ nền khả dụng cho cả wizard fullscreen và cửa sổ PiP, trong code thực tế nên đưa useLongGiftJob vào context và đọc qua useLongGiftJobContext. Bỏ qua chi tiết triển khai context (Provider, createContext): điều quan trọng là job‑state sống ở một nơi, còn các lớp UI khác nhau chỉ subscribe vào nó.

Và một component riêng cho hiển thị PiP:

const GiftPipView: React.FC<{ status: string }> = ({ status }) => {
  return (
    <div>
      <p>GiftGenius đang chạy…</p>
      <p>Trạng thái: {status === "running" ? "đang chạy" : "xong"}</p>
      <button
        onClick={() => window.openai.requestDisplayMode({ mode: "fullscreen" })}
      >
        Mở rộng
      </button>
    </div>
  );
};

Trong widget tổng, ta điều chỉnh render để tính cả PiP:

const GiftGeniusWidget: React.FC = () => {
  const mode = useDisplayMode();
  const { status } = useLongGiftJobContext(); // qua context như bàn ở trên

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

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

  return <InlineGiftPreview onExpand={/* như trước */} />;
};

Kịch bản này kết hợp tuyệt vời với chế độ giọng nói (sẽ nói ở bài voice): bằng giọng nói ta khởi chạy chọn quà, PiP hiển thị tiến độ, chat vẫn ở bên dưới và tiếp tục “sống” như bình thường.

9. Video + chat: khi fullscreen và PiP trở thành media player

Lịch sử cho thấy PiP thường gắn với video nằm ở góc màn hình. Vì vậy hợp lý khi tách riêng kịch bản “video + chat”. Không có phép màu nào ở đây: trong hầu hết trường hợp bạn chỉ render video ở fullscreen hoặc cửa sổ PiP. Tài liệu OpenAI nêu rõ các kịch bản media như ví dụ điển hình cho fullscreen và PiP.

Điều này có ý nghĩa gì với GiftGenius? Ví dụ:

  • bạn hiển thị video promo của món quà;
  • một hướng dẫn ngắn “cách gói quà đẹp”;
  • video review vài sản phẩm.

Trong fullscreen, có thể render <video> đầy đủ với mô tả và gợi ý; trong PiP — chỉ giữ player và có thể một tiêu đề nhỏ.

Component bọc đơn giản:

const GiftVideoPlayer: React.FC<{ src: string; title: string }> = ({
  src,
  title,
}) => (
  <div>
    <h3>{title}</h3>
    <video
      src={src}
      controls
      style={{ width: "100%", borderRadius: 8 }}
    />
  </div>
);

Trong wizard fullscreen, ta có thể gợi ý người dùng “Xem video review cho món quà này”, rồi thu nhỏ nó vào PiP:

const WatchVideoStep: React.FC = () => {
  const requestDisplayMode = useRequestDisplayMode();

  return (
    <div>
      <GiftVideoPlayer src="/videos/gift-wrap.mp4" title="Cách gói quà" />
      <button
        onClick={() => requestDisplayMode({ mode: "pip" })}
      >
        Giữ video ở góc và quay lại chat
      </button>
    </div>
  );
};

Một vài mẹo thực tế cho kịch bản media:

  • đừng bật tự động phát kèm âm thanh — đây là anti‑pattern UX kinh điển;
  • đảm bảo có phụ đề và có thể tạm dừng bằng bàn phím (phím cách, phím mũi tên);
  • trong cửa sổ PiP, đừng cố hiển thị toàn bộ nội dung kèm theo, chỉ cần video.

10. Trạng thái, remount widget và đặc thù di động

Câu hỏi khó chịu nhất thường được hỏi lúc này: “Trạng thái React có được giữ nếu tôi chuyển từ inline sang fullscreen rồi về lại không?”

Câu trả lời ngắn: đừng dựa vào điều đó.

Về kỹ thuật, hành vi phụ thuộc vào phiên bản SDK và cách host triển khai: có nơi chuyển chế độ mà không tạo lại iframe, nơi khác widget bị unmount rồi mount lại. Tài liệu cũng nhấn mạnh rằng việc giữ nguyên context khi đổi chế độ phụ thuộc vào bản SDK và không được đảm bảo cho nhà phát triển.

Cách tiếp cận thực tế:

  1. Lưu mọi trạng thái quan trọng (bước wizard, dữ liệu đã nhập, định danh tác vụ nền) ở:
    • backend (qua MCP server của bạn và token phiên),
    • hoặc trong ngữ cảnh ChatGPT (ví dụ, qua tools trả về “trạng thái workflow hiện tại”),
    • hoặc trong tham số URL/local storage nếu có cơ sở an toàn.
  2. Dùng React state như lớp cache/UI, nhưng sẵn sàng việc nó bị reset khi đổi chế độ — khi đó bạn khôi phục từ nguồn đáng tin cậy hơn.

Điểm tinh tế thứ hai liên quan đến kết quả của requestDisplayMode. Như đã nói, yêu cầu với mode "pip" có thể trả về "fullscreen", nhất là trên di động nơi PiP thật có thể không hỗ trợ hoặc tự mở toàn màn hình.

Mẫu điển hình:

const requestDisplayMode = useRequestDisplayMode();

const openPipSafe = async () => {
  const result = await requestDisplayMode({ mode: "pip" });
  if (result.mode !== "pip") {
    // Fallback: ví dụ, hiển thị thông báo hoặc điều chỉnh UI cho fullscreen
    console.log("PiP không khả dụng, chúng ta sẽ chạy ở chế độ:", result.mode);
  }
};

Cách này giúp bạn không rơi vào tình huống trông chờ một cửa sổ nhỏ nhưng lại nhận UI toàn màn hình với các nút “dành riêng cho PiP”, trông sẽ rất kỳ.

Cuối cùng, lưu ý maxHeight và cuộn nội bộ: ngay cả trong fullscreen host vẫn có thể giới hạn chiều cao container; nhiệm vụ của bạn là tổ chức cuộn sao cho không xuất hiện ba thanh cuộn lồng nhau.

11. Lỗi thường gặp khi làm việc với fullscreen và PiP

Lỗi số 1: Dùng fullscreen như chế độ mặc định.
Một số nhà phát triển thấy chữ “fullscreen” là muốn biến App của mình thành một SPA riêng bên trong chat. Kết quả: chỉ cần nhắc đến quà là người dùng lập tức bị đẩy vào wizard toàn màn hình, dù họ chỉ cần vài ý tưởng. Guidelines của OpenAI khuyến nghị mạnh mẽ bắt đầu với inline và chỉ mở rộng sang fullscreen khi thật sự cần.

Lỗi số 2: Xem PiP như một fullscreen thu nhỏ.
PiP có diện tích rất hạn chế, nhưng đôi khi người ta cố nhét mọi thứ: tab, form, bộ lọc. Người dùng nhận một giao diện siêu nhỏ, khó mà bấm chính xác. Cách đúng — trong PiP chỉ hiển thị trạng thái và một hai nút chính (ví dụ, “Mở rộng” và “Hủy”).

Lỗi số 3: Chuyển chế độ không có giải thích.
Khi widget bất ngờ mở fullscreen mà không có lời nhắn từ GPT hoặc không có cú click rõ ràng của người dùng, điều đó gây khó chịu. Tương tự với tự động thu nhỏ sang PiP hay trả về inline. Mỗi lần chuyển nên kèm một câu giải thích ngắn trong tin nhắn của mô hình: “Bây giờ mình sẽ mở wizard chi tiết” trước fullscreen, “Mình sẽ thu nhỏ quá trình chọn vào cửa sổ nhỏ trong khi xử lý” trước PiP.

Lỗi số 4: Bỏ qua di động và khác biệt nền tảng.
Nhà phát triển chỉ test trên desktop, nơi PiP hoạt động như mong đợi, còn trên di động mọi thứ chuyển thành fullscreen, layout vỡ, nút nằm ngoài safe‑area. Tài liệu cảnh báo rõ ràng rằng PiP trên di động có thể được triển khai như fullscreen, và hành vi có thể thay đổi giữa các phiên bản SDK, vì vậy cần test trên thiết bị mục tiêu và làm việc cẩn thận với requestDisplayMode.

Lỗi số 5: Tin tưởng tuyệt đối vào việc giữ trạng thái khi đổi chế độ.
Chỉ dựa vào React state mà không có hỗ trợ lưu trữ/duy trì trên server dẫn đến những tình huống dở khóc: người dùng đã đi qua hai bước, nhấn “Thu nhỏ sang PiP”, quay lại thì ở bước đầu với các trường trống. Hãy mặc định rằng khi đổi chế độ, component của bạn có thể bị unmount, và thiết kế quản lý state với rủi ro đó trong đầu.

Lỗi số 6: Quên mất khả năng truy cập của wizard fullscreen.
Một form đẹp trên màn hình lớn không phải lúc nào cũng thân thiện với người có thị lực kém hoặc chỉ dùng bàn phím. Chữ quá nhỏ, tương phản thấp, nút “Tiếp theo” và “Quay lại” khó đọc — là nguyên nhân phổ biến không chỉ của UX tệ mà còn rắc rối khi review trên Store. Hãy kiểm tra ít nhất những điều cơ bản: tương phản văn bản, cỡ chữ, Tab‑navigation và nhãn văn bản rõ ràng cho các nút.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION