diff --git a/docs/patterns.md b/docs/patterns.md index ab4a9f1..aff095c 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -54,13 +54,20 @@ Listen for display mode changes via {@link app!App.onhostcontextchanged onhostco {@includeCode ../src/app.examples.ts#App_onhostcontextchanged_respondToDisplayMode} -## [TODO] Persist data (incl. widget state) +## Persisting widget state -- Note: OAI's `window.openai.setWidgetState({modelContent, privateContent, imageIds})` has only a partial equivalent in the MCP Apps spec (for now!): `App.updateModelContext({content, structuredContent})` -- For data persistence / to reload when conversation is reloaded, you must use localStorage / IndexedDb with `hostInfo.toolInfo.id` as key returned `CallToolResult._meta.widgetUUID = randomUUID()` +To persist widget state across conversation reloads (e.g., current page in a PDF viewer, camera position in a map), use [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) with a stable identifier provided by the server. + +**Server-side**: Tool handler generates a unique `widgetUUID` and returns it in `CallToolResult._meta.widgetUUID`: + +{@includeCode ./patterns.tsx#persistDataServer} + +**Client-side**: Receive the UUID in {@link app!App.ontoolresult ontoolresult} and use it as the storage key: {@includeCode ./patterns.tsx#persistData} +> **Note:** For model-visible state (informing the LLM about what the user is viewing), use {@link app!App.updateModelContext updateModelContext} instead. Widget state persistence is for UI state that should survive page reloads but doesn't need to be seen by the model. + ## Lowering perceived latency Use {@link app!App.ontoolinputpartial ontoolinputpartial} to receive streaming tool arguments as they arrive, allowing you to show a loading preview before the complete input is available. diff --git a/docs/patterns.tsx b/docs/patterns.tsx index a6fd259..6002bfc 100644 --- a/docs/patterns.tsx +++ b/docs/patterns.tsx @@ -13,6 +13,8 @@ import { applyHostFonts, applyHostStyleVariables, } from "../src/styles.js"; +import { randomUUID } from "node:crypto"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { McpUiHostContext } from "../src/types.js"; import { useApp, useHostStyles } from "../src/react/index.js"; @@ -87,16 +89,72 @@ function hostStylingReact() { } /** - * Example: Persist data (incl. widget state) + * Example: Persisting widget state (server-side) */ -function persistData(app: App) { +function persistWidgetStateServer( + url: string, + title: string, + pageCount: number, +) { + function toolCallback(): CallToolResult { + //#region persistDataServer + // In your tool callback, include widgetUUID in the result metadata. + return { + content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }], + structuredContent: { url, title, pageCount, initialPage: 1 }, + _meta: { + widgetUUID: randomUUID(), + }, + }; + //#endregion persistDataServer + } +} + +/** + * Example: Persisting widget state (client-side) + */ +function persistWidgetState(app: App) { //#region persistData - // Note: OAI's window.openai.setWidgetState({modelContent, privateContent, imageIds}) - // has only a partial equivalent in MCP Apps: App.updateModelContext({content, structuredContent}) - // For data persistence / to reload when conversation is reloaded, - // use localStorage / IndexedDb with hostInfo.toolInfo.id as key - // returned CallToolResult._meta.widgetUUID = randomUUID() - // TODO: Complete implementation + // Store the widgetUUID received from the server + let widgetUUID: string | undefined; + + // Helper to save state to localStorage + function saveState(state: T): void { + if (!widgetUUID) return; + try { + localStorage.setItem(widgetUUID, JSON.stringify(state)); + } catch (err) { + console.error("Failed to save widget state:", err); + } + } + + // Helper to load state from localStorage + function loadState(): T | null { + if (!widgetUUID) return null; + try { + const saved = localStorage.getItem(widgetUUID); + return saved ? (JSON.parse(saved) as T) : null; + } catch (err) { + console.error("Failed to load widget state:", err); + return null; + } + } + + // Receive widgetUUID from the tool result + app.ontoolresult = (result) => { + widgetUUID = result._meta?.widgetUUID + ? String(result._meta.widgetUUID) + : undefined; + + // Restore any previously saved state + const savedState = loadState<{ currentPage: number }>(); + if (savedState) { + // Apply restored state to your UI... + } + }; + + // Call saveState() whenever your widget state changes + // e.g., saveState({ currentPage: 5 }); //#endregion persistData } @@ -123,6 +181,7 @@ function migrateFromOpenai() { void authenticatedCalls; void hostStylingVanillaJs; void hostStylingReact; -void persistData; +void persistWidgetStateServer; +void persistWidgetState; void iframeAndMcpApps; void migrateFromOpenai;