diff --git a/package-lock.json b/package-lock.json index eb5e1f6..1da0f5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -175,6 +175,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -583,6 +584,7 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -717,6 +719,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -740,6 +743,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4708,6 +4712,7 @@ "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5013,6 +5018,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5023,6 +5029,7 @@ "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -5124,6 +5131,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -5519,6 +5527,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5983,6 +5992,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -7093,6 +7103,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7170,6 +7181,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12113,6 +12125,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12263,6 +12276,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12321,6 +12335,7 @@ "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12350,6 +12365,7 @@ "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "prettier": ">=2.0", "typescript": ">=2.9", @@ -12701,6 +12717,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12771,6 +12788,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -13406,6 +13424,7 @@ "integrity": "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.6" }, @@ -13928,6 +13947,7 @@ "integrity": "sha512-sVKbCj/OTx67jhmauhxc2dcr1P+yOgz/x3h0krwjyMgdc5Oubvxyg4NYDZmzAw+ym36g/lzH8N0Ccp4dwtdfxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/core": "8.6.14" }, @@ -14238,6 +14258,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", @@ -15001,6 +15022,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15664,6 +15686,7 @@ "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", @@ -16821,6 +16844,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -17206,6 +17230,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -17281,6 +17306,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/global.d.ts b/src/global.d.ts index 4644dba..457617e 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,3 +1,7 @@ +/** + * Global oai object injected by the web sandbox for communicating with chatgpt host page. + */ +import type { API, OpenAiGlobals, type SET_GLOBALS_EVENT_TYPE, type SetGlobalsEvent } from "./types" declare global { interface DefaultConfig { LinkComponent: "a" @@ -16,6 +20,13 @@ declare global { export type LinkComponent = Config["LinkComponent"] export type Breakpoint = Config["Breakpoint"] } + interface Window { + openai: API & OpenAiGlobals + } + + interface WindowEventMap { + [SET_GLOBALS_EVENT_TYPE]: SetGlobalsEvent + } } export {} diff --git a/src/hooks/OpenAI globals.mdx b/src/hooks/OpenAI globals.mdx new file mode 100644 index 0000000..432440c --- /dev/null +++ b/src/hooks/OpenAI globals.mdx @@ -0,0 +1,96 @@ +import { Meta, Subtitle, Title } from "@storybook/blocks" + + + +OpenAI globals hooks +Subscribe to ChatGPT host values like layout and widget state + +## Overview +ChatGPT injects a `window.openai` object that includes layout details, locale, safe area, tool IO, and widget state. The hooks below subscribe to `openai:set_globals` events so your UI stays in sync with host updates. Until the host provides data they return `null`, so add sensible fallbacks for local development. See the [ChatGPT UI guide](https://developers.openai.com/apps-sdk/build/chatgpt-ui#managing-state) for the full host API. + +## State placement +- **Business data (authoritative)** – lives on your MCP server or backend; return fresh snapshots via tools. +- **Widget UI state (ephemeral)** – lives in `window.openai.widgetState` and should be updated through `useWidgetState` after every meaningful UI change. +- **Cross-session state (durable)** – store in your backend if it must survive across conversations or devices. + +## `useOpenAiGlobal` +Base hook for reading any key from `window.openai` with live updates. Pass the key you want and receive a typed value. + +```tsx +import { useOpenAiGlobal } from "@openai/apps-sdk-ui/hooks/useOpenaiGlobal" + +function SafeAreaAwarePanel() { + const safeArea = useOpenAiGlobal("safeArea") + const locale = useOpenAiGlobal("locale") + + return ( +
+

Current locale: {locale ?? "loading..."}

+
+ ) +} +``` + +## Layout hooks +### `useDisplayMode` +Returns the current display mode (`pip`, `inline`, or `fullscreen`) granted by the host. Useful for adjusting density or controls when PiP or fullscreen is active. + +### `useMaxHeight` +Returns the available vertical space in pixels for the widget, or `null` while loading. Combine with overflow styles to keep content scrollable. + +```tsx +import { useDisplayMode } from "@openai/apps-sdk-ui/hooks/useDisplayMode" +import { useMaxHeight } from "@openai/apps-sdk-ui/hooks/useMaxHeight" + +function WidgetFrame({ children }: { children: React.ReactNode }) { + const displayMode = useDisplayMode() + const maxHeight = useMaxHeight() + + return ( +
+ {children} +
+ ) +} +``` + +## Widget data +### `useWidgetProps` +Reads the latest `toolOutput` provided by the host and falls back to an optional default (value or lazy initializer) when developing outside ChatGPT. + +```tsx +import { useWidgetProps } from "@openai/apps-sdk-ui/hooks/useWidgetProps" + +type ReservationProps = { venue: string; time: string } + +const props = useWidgetProps(() => ({ venue: "Loading", time: "" })) +``` + +### `useWidgetState` +Synchronizes widget UI state with ChatGPT. The hook hydrates from `window.openai.widgetState` when the widget mounts (or falls back to your initializer), subscribes to host changes, and mirrors writes through `window.openai.setWidgetState` so the host persists the snapshot for this widget instance. State is message-scoped and survives re-opens of the same widget but not new runs. + +```tsx +import { useWidgetState } from "@openai/apps-sdk-ui/hooks/useWidgetState" + +type Filters = { cuisine: string | null; guests: number } + +function FiltersPanel() { + const [filters, setFilters] = useWidgetState(() => ({ cuisine: null, guests: 2 })) + + return ( +
+ + +
+ ) +} +``` + +All hooks return `null` until `window.openai` is available. Provide defaults where appropriate so your components render gracefully in non-ChatGPT environments. diff --git a/src/hooks/useDisplayMode.ts b/src/hooks/useDisplayMode.ts new file mode 100644 index 0000000..26fe03a --- /dev/null +++ b/src/hooks/useDisplayMode.ts @@ -0,0 +1,6 @@ +import { type DisplayMode } from "../types" +import { useOpenAiGlobal } from "./useOpenaiGlobal" + +export const useDisplayMode = (): DisplayMode | null => { + return useOpenAiGlobal("displayMode") +} diff --git a/src/hooks/useMaxHeight.ts b/src/hooks/useMaxHeight.ts new file mode 100644 index 0000000..2a412e2 --- /dev/null +++ b/src/hooks/useMaxHeight.ts @@ -0,0 +1,5 @@ +import { useOpenAiGlobal } from "./useOpenaiGlobal" + +export const useMaxHeight = (): number | null => { + return useOpenAiGlobal("maxHeight") +} diff --git a/src/hooks/useOpenaiGlobal.ts b/src/hooks/useOpenaiGlobal.ts new file mode 100644 index 0000000..e965b47 --- /dev/null +++ b/src/hooks/useOpenaiGlobal.ts @@ -0,0 +1,32 @@ +import { useSyncExternalStore } from "react" +import { SET_GLOBALS_EVENT_TYPE, SetGlobalsEvent, type OpenAiGlobals } from "../types" + +export function useOpenAiGlobal(key: K): OpenAiGlobals[K] | null { + return useSyncExternalStore( + (onChange) => { + if (typeof window === "undefined") { + return () => {} + } + + const handleSetGlobal: EventListener = (event) => { + const { globals } = (event as SetGlobalsEvent).detail + const value = globals[key] + if (value === undefined) { + return + } + + onChange() + } + + window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, { + passive: true, + }) + + return () => { + window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal) + } + }, + () => window.openai?.[key] ?? null, + () => window.openai?.[key] ?? null, + ) +} diff --git a/src/hooks/useWidgetProps.ts b/src/hooks/useWidgetProps.ts new file mode 100644 index 0000000..4d84e20 --- /dev/null +++ b/src/hooks/useWidgetProps.ts @@ -0,0 +1,10 @@ +import { useOpenAiGlobal } from "./useOpenaiGlobal" + +export function useWidgetProps>(defaultState?: T | (() => T)): T { + const props = useOpenAiGlobal("toolOutput") as T + + const fallback = + typeof defaultState === "function" ? (defaultState as () => T | null)() : defaultState ?? null + + return props ?? fallback +} diff --git a/src/hooks/useWidgetState.ts b/src/hooks/useWidgetState.ts new file mode 100644 index 0000000..4a5d424 --- /dev/null +++ b/src/hooks/useWidgetState.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState, type SetStateAction } from "react" +import type { UnknownObject } from "../types" +import { useOpenAiGlobal } from "./useOpenaiGlobal" + +export function useWidgetState( + defaultState: T | (() => T), +): readonly [T, (state: SetStateAction) => void] +export function useWidgetState( + defaultState?: T | (() => T | null) | null, +): readonly [T | null, (state: SetStateAction) => void] +export function useWidgetState( + defaultState?: T | (() => T | null) | null, +): readonly [T | null, (state: SetStateAction) => void] { + const widgetStateFromWindow = useOpenAiGlobal("widgetState") as T + + const [widgetState, _setWidgetState] = useState(() => { + if (widgetStateFromWindow != null) { + return widgetStateFromWindow + } + + return typeof defaultState === "function" ? defaultState() : defaultState ?? null + }) + + useEffect(() => { + _setWidgetState(widgetStateFromWindow) + }, [widgetStateFromWindow]) + + const setWidgetState = useCallback( + (state: SetStateAction) => { + _setWidgetState((prevState) => { + const newState = typeof state === "function" ? state(prevState) : state + + if (newState != null) { + window.openai.setWidgetState(newState) + } + + return newState + }) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [window.openai.setWidgetState], + ) + + return [widgetState, setWidgetState] as const +} diff --git a/src/types.ts b/src/types.ts index b80b8c0..891c88e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,3 +46,89 @@ export type Alignments = T export type FontWeight = "inherit" | "normal" | "medium" | "semibold" | "bold" export type FontWeights = T + +export type OpenAiGlobals< + ToolInput = UnknownObject, + ToolOutput = UnknownObject, + ToolResponseMetadata = UnknownObject, + WidgetState = UnknownObject, +> = { + // visuals + theme: Theme + + userAgent: UserAgent + locale: string + + // layout + maxHeight: number + displayMode: DisplayMode + safeArea: SafeArea + + // state + toolInput: ToolInput + toolOutput: ToolOutput | null + toolResponseMetadata: ToolResponseMetadata | null + widgetState: WidgetState | null + setWidgetState: (state: WidgetState) => Promise +} + +export type API = { + callTool: CallTool + sendFollowUpMessage: (args: { prompt: string }) => Promise + openExternal(payload: { href: string }): void + + // Layout controls + requestDisplayMode: RequestDisplayMode + requestModal: (args: { title?: string; params?: UnknownObject }) => Promise + requestClose: () => Promise +} + +export type UnknownObject = Record + +export type Theme = "light" | "dark" + +export type SafeAreaInsets = { + top: number + bottom: number + left: number + right: number +} + +export type SafeArea = { + insets: SafeAreaInsets +} + +export type DeviceType = "mobile" | "tablet" | "desktop" | "unknown" + +export type UserAgent = { + device: { type: DeviceType } + capabilities: { + hover: boolean + touch: boolean + } +} + +/** Display mode */ +export type DisplayMode = "pip" | "inline" | "fullscreen" +export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{ + /** + * The granted display mode. The host may reject the request. + * For mobile, PiP is always coerced to fullscreen. + */ + mode: DisplayMode +}> + +export type CallToolResponse = { + result: string +} + +/** Calling APIs */ +export type CallTool = (name: string, args: Record) => Promise + +/** Extra events */ +export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals" +export class SetGlobalsEvent extends CustomEvent<{ + globals: Partial +}> { + readonly type = SET_GLOBALS_EVENT_TYPE +}