diff --git a/README.md b/README.md index 02c07f6..64a425a 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ npm run dev # Run server + vite dev (hot reload) | Method | Description | |--------|-------------| | `ui/notifications/initialized` | Widget is fully initialized | -| `ui/size-change` | Widget requests a size change | +| `ui/notifications/size-change` | Widget requests a size change | ### Host → Widget Notifications diff --git a/index.html b/index.html index 284e081..4d37edf 100644 --- a/index.html +++ b/index.html @@ -1,15 +1,12 @@ - + MCP Apps Widget - - -
+ +
diff --git a/package.json b/package.json index eae8b6c..87f863f 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,13 @@ "dev:server": "tsx watch server/index.ts", "dev:widget": "vite", "build": "vite build", - "start": "npm run dev" + "start": "tsx server/index.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.52.0", "@modelcontextprotocol/ext-apps": "file:../ext-apps", "@modelcontextprotocol/sdk": "^1.22.0", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/vite": "^4.1.17", "class-variance-authority": "^0.7.1", diff --git a/server/index.ts b/server/index.ts index 456e517..a2af893 100644 --- a/server/index.ts +++ b/server/index.ts @@ -33,27 +33,7 @@ const CSP_PERMISSIVE_CONFIG = { // Session management const transports = new Map(); -// Tips data for demo -const tips = [ - { - id: "what-is-mcp", - title: "What is MCP?", - content: "Model Context Protocol (MCP) is an open protocol that enables seamless integration between AI applications and external data sources. It provides a standardized way for AI models to access tools, resources, and prompts.", - emoji: "🔌", - }, - { - id: "what-are-apps", - title: "What are MCP Apps?", - content: "MCP Apps (SEP-1865) extend MCP to deliver rich, interactive user interfaces. They enable servers to embed HTML widgets in tool responses, creating dynamic experiences beyond plain text.", - emoji: "✨", - }, - { - id: "how-resources-work", - title: "How do Resources work?", - content: "Resources in MCP are identified by URIs. Widgets can read resources using the resources/read API, enabling dynamic data fetching from the server. This tip was loaded using that exact API!", - emoji: "📚", - }, -]; + function createServer() { const server = new McpServer({ name: "mcp-apps-everything", version: "0.0.1" }); @@ -104,32 +84,27 @@ function createServer() { contents: [{ uri: "ui://counter", mimeType: "text/html+mcp", text: getWidgetHtml() }], })); - // ===================================== - // DATA RESOURCES (tips:// scheme) - // ===================================== - // Individual tip resources - for (const tip of tips) { - server.resource( - `tip-${tip.id}`, - `tips://${tip.id}`, - { mimeType: "application/json" }, - async () => ({ - contents: [ - { - uri: `tips://${tip.id}`, - mimeType: "application/json", - text: JSON.stringify(tip), - }, - ], - }) - ); - } // ===================================== // TOOLS WITH UI (use registerTool for _meta) // ===================================== + // Dashboard Widget - Shows navigation dashboard + server.registerTool( + "dashboard", + { + title: "Dashboard", + description: "Shows the dashboard with all available widgets", + inputSchema: {}, + _meta: { [RESOURCE_URI_META_KEY]: "ui://main" }, + }, + async (): Promise => ({ + content: [{ type: "text", text: "Dashboard loaded. Select a widget to explore." }], + structuredContent: { _widget: "dashboard" }, + }) + ); + // Tool Call Widget - Demonstrates tools/call server.registerTool( "tool-call", @@ -157,65 +132,23 @@ function createServer() { "open-link", { title: "Open Link Demo", - description: "Weather widget that demonstrates the ui/open-link API for opening external URLs", - inputSchema: { - location: z.string().default("San Francisco").describe("Location to show weather for"), - temperature: z.number().optional().describe("Temperature in Celsius"), - condition: z - .enum(["sunny", "cloudy", "rainy", "snowy", "stormy", "windy"]) - .optional() - .describe("Weather condition"), - humidity: z.number().optional().describe("Humidity percentage"), - wind: z.number().optional().describe("Wind speed in km/h"), - }, - _meta: { [RESOURCE_URI_META_KEY]: "ui://main" }, - }, - async ({ location, temperature, condition, humidity, wind }): Promise => { - const weatherData = { - location, - temperature: temperature ?? Math.floor(Math.random() * 30) + 5, - condition: condition ?? ["sunny", "cloudy", "rainy"][Math.floor(Math.random() * 3)], - humidity: humidity ?? Math.floor(Math.random() * 60) + 30, - wind: wind ?? Math.floor(Math.random() * 30) + 5, - }; - - return { - content: [ - { - type: "text", - text: `Weather for ${location}: ${weatherData.temperature}°C, ${weatherData.condition}. Click links to open external services.`, - }, - ], - structuredContent: { _widget: "open-link", ...weatherData }, - }; - } - ); - - // Read Resource Widget - Demonstrates resources/read - server.registerTool( - "read-resource", - { - title: "Read Resource Demo", - description: "Demonstrates the resources/read API for reading MCP resources", + description: "Shows a list of MCP-related resources that demonstrates the ui/open-link API", inputSchema: {}, _meta: { [RESOURCE_URI_META_KEY]: "ui://main" }, }, - async (): Promise => { - return { - content: [ - { - type: "text", - text: `Ready to read resources. Click a topic to fetch it via resources/read.`, - }, - ], - structuredContent: { - _widget: "read-resource", - availableTips: tips.map(t => ({ id: t.id, title: t.title, emoji: t.emoji })) + async (): Promise => ({ + content: [ + { + type: "text", + text: "MCP Resources loaded. Click any link to open it in your browser.", }, - }; - } + ], + structuredContent: { _widget: "open-link" }, + }) ); + + // Message Widget - Demonstrates ui/message server.registerTool( "message", @@ -288,12 +221,12 @@ function createServer() { } ); - // Size Change Widget - Demonstrates ui/size-change + // Size Change Widget - Demonstrates ui/notifications/size-change server.registerTool( "size-change", { title: "Size Change Demo", - description: "Interactive widget that demonstrates the ui/size-change API for dynamically changing widget height", + description: "Interactive widget that demonstrates the ui/notifications/size-change API for dynamically changing widget height", inputSchema: { height: z.number().default(300).describe("Initial height in pixels"), }, @@ -387,19 +320,18 @@ app.listen(PORT, () => { ║ Endpoint: http://localhost:${PORT}/mcp ║ ╠═══════════════════════════════════════════════════════════════╣ ║ Tools with UI: ║ +║ • dashboard - Navigation dashboard ║ ║ • tool-call - tools/call demo ║ ║ • open-link - ui/open-link demo ║ -║ • read-resource - resources/read demo ║ ║ • message - ui/message demo ║ ║ • csp-test - CSP enforcement demo ║ -║ • size-change - ui/size-change demo ║ +║ • size-change - ui/notifications/size-change demo ║ ╠═══════════════════════════════════════════════════════════════╣ ║ Utility Tools: ║ ║ • increment - Counter increment ║ ╠═══════════════════════════════════════════════════════════════╣ ║ Resources: ║ ║ • ui://main - Widget HTML ║ -║ • tips://{id} - Tips about MCP ║ ╚═══════════════════════════════════════════════════════════════╝ `); }); diff --git a/vite.config.ts b/vite.config.ts index 864a31b..eb0acf8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ "@": path.resolve(__dirname, "./widget"), }, }, + server: { + port: 5874, + }, build: { outDir: "dist", emptyOutDir: true, diff --git a/widget/App.tsx b/widget/App.tsx index 3810787..7af8b18 100644 --- a/widget/App.tsx +++ b/widget/App.tsx @@ -9,13 +9,13 @@ * - ui/open-link: Open Link Widget * - resources/read: Read Resource Widget * - ui/message: Message Widget - * - ui/size-change: Size Change Widget + * - ui/notifications/size-change: Size Change Widget */ import { useEffect, useState } from "react"; import { + useApp, App, - PostMessageTransport, McpUiToolInputNotificationSchema, McpUiToolResultNotificationSchema, McpUiHostContextChangedNotificationSchema, @@ -23,12 +23,13 @@ import { import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { ToolCallWidget } from "./widgets/ToolCallWidget"; import { OpenLinkWidget } from "./widgets/OpenLinkWidget"; -import { ReadResourceWidget } from "./widgets/ReadResourceWidget"; import { MessageWidget } from "./widgets/MessageWidget"; import { CspTestWidget } from "./widgets/CspTestWidget"; import { SizeChangeWidget } from "./widgets/SizeChangeWidget"; +import { Dashboard } from "./widgets/Dashboard"; +import { Navbar } from "./components/Navbar"; -type WidgetType = "tool-call" | "open-link" | "read-resource" | "message" | "csp-test" | "size-change" | null; +export type WidgetType = "tool-call" | "open-link" | "message" | "csp-test" | "size-change" | "dashboard" | null; interface ToolInput { arguments: Record; @@ -45,65 +46,37 @@ interface HostContext { } export function AppComponent() { - // App connection state - const [app, setApp] = useState(null); - const [isConnected, setIsConnected] = useState(false); - const [error, setError] = useState(null); - // Local state for notifications const [toolInput, setToolInput] = useState(null); const [toolResult, setToolResult] = useState(null); const [hostContext, setHostContext] = useState(null); - - // Create and connect App manually with autoResize disabled - useEffect(() => { - let mounted = true; - - async function connect() { - try { - const myApp = new App( - { name: "mcp-apps-everything", version: "0.0.1" }, - {}, // capabilities - { autoResize: false } - ); - - // Register notification handlers BEFORE connecting - myApp.setNotificationHandler(McpUiToolInputNotificationSchema, (n) => { - setToolInput({ arguments: n.params.arguments ?? {} }); - }); - - myApp.setNotificationHandler(McpUiToolResultNotificationSchema, (n) => { - setToolResult({ - content: n.params.content, - structuredContent: n.params.structuredContent, - isError: n.params.isError, - }); - }); - - myApp.setNotificationHandler(McpUiHostContextChangedNotificationSchema, (n) => { - setHostContext((prev) => ({ ...prev, ...n.params })); + + // Navigation state + const [currentView, setCurrentView] = useState(null); + + // Use the SDK directly + const { app, isConnected, error } = useApp({ + appInfo: { name: "mcp-apps-everything", version: "0.0.1" }, + capabilities: {}, + onAppCreated: (app: App) => { + // Register notification handlers before connection + app.setNotificationHandler(McpUiToolInputNotificationSchema, (n) => { + setToolInput({ arguments: n.params.arguments ?? {} }); + }); + + app.setNotificationHandler(McpUiToolResultNotificationSchema, (n) => { + setToolResult({ + content: n.params.content, + structuredContent: n.params.structuredContent, + isError: n.params.isError, }); + }); - const transport = new PostMessageTransport(window.parent); - await myApp.connect(transport); - - if (mounted) { - setApp(myApp); - setIsConnected(true); - } - } catch (err) { - if (mounted) { - setError(err instanceof Error ? err : new Error("Failed to connect")); - } - } - } - - connect(); - - return () => { - mounted = false; - }; - }, []); + app.setNotificationHandler(McpUiHostContextChangedNotificationSchema, (n) => { + setHostContext((prev) => ({ ...prev, ...n.params })); + }); + }, + }); const isDark = hostContext?.theme === "dark"; @@ -120,6 +93,9 @@ export function AppComponent() { const widgetType: WidgetType = (toolResult?.structuredContent?._widget as WidgetType) || null; + // Determine what to render + const renderView = currentView || widgetType; + // Loading state or error if (!isConnected) { return ( @@ -141,7 +117,7 @@ export function AppComponent() { } // No widget detected - if (!widgetType) { + if (!renderView) { return (
Waiting for tool result...
@@ -149,22 +125,33 @@ export function AppComponent() { ); } + // Dashboard view + if (renderView === "dashboard") { + return ; + } + return ( -
- {widgetType === "tool-call" && ( +
+ {/* Show navbar when navigating from dashboard */} + {currentView && ( + setCurrentView(null)} + /> + )} + + {renderView === "tool-call" && ( )} - {widgetType === "open-link" && ( + {renderView === "open-link" && ( )} - {widgetType === "read-resource" && ( - - )} - {widgetType === "message" && } - {widgetType === "csp-test" && ( + + {renderView === "message" && } + {renderView === "csp-test" && ( )} - {widgetType === "size-change" && ( + {renderView === "size-change" && ( )}
diff --git a/widget/components/Navbar.tsx b/widget/components/Navbar.tsx new file mode 100644 index 0000000..43cf170 --- /dev/null +++ b/widget/components/Navbar.tsx @@ -0,0 +1,34 @@ +/** + * Shared Navigation Bar + * Shows back button when viewing individual widgets + */ + +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { App } from "@modelcontextprotocol/ext-apps/react"; + +interface NavbarProps { + app: App; + onBack: () => void; +} + +export function Navbar({ app, onBack }: NavbarProps) { + const handleBack = () => { + // Reset size to 400px when going back to dashboard + app.sendSizeChange({ height: 400 }); + onBack(); + }; + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/widget/widgets/Dashboard.tsx b/widget/widgets/Dashboard.tsx new file mode 100644 index 0000000..ea59a36 --- /dev/null +++ b/widget/widgets/Dashboard.tsx @@ -0,0 +1,88 @@ +/** + * Dashboard Widget - Shows all available widgets for navigation + */ + +import { + MousePointerClick, + ExternalLink, + MessageSquare, + Shield, + Maximize2 +} from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import type { WidgetType } from "../App"; + +interface DashboardProps { + onNavigate: (widget: WidgetType) => void; +} + +const WIDGETS = [ + { + id: "tool-call" as WidgetType, + name: "Tool Call", + description: "Demonstrates tools/call API", + icon: MousePointerClick + }, + { + id: "open-link" as WidgetType, + name: "Open Link", + description: "Demonstrates the ui/open-link API", + icon: ExternalLink + }, + { + id: "message" as WidgetType, + name: "Message", + description: "Demonstrates the ui/message API", + icon: MessageSquare + }, + { + id: "csp-test" as WidgetType, + name: "CSP Test", + description: "Tests Content Security Policy", + icon: Shield + }, + { + id: "size-change" as WidgetType, + name: "Size Change", + description: "Demonstrates the ui/notifications/size-change API", + icon: Maximize2 + }, +]; + +export function Dashboard({ onNavigate }: DashboardProps) { + return ( +
+
+

MCP Apps Dashboard

+

+ Select a widget to explore different MCP APIs and capabilities +

+
+ +
+ {WIDGETS.map((widget) => { + const Icon = widget.icon; + return ( + onNavigate(widget.id)} + > + +
+
+ +
+ {widget.name} +
+
+ + {widget.description} + +
+ ); + })} +
+
+ ); +} diff --git a/widget/widgets/OpenLinkWidget.tsx b/widget/widgets/OpenLinkWidget.tsx index e9a4b6e..9e084cb 100644 --- a/widget/widgets/OpenLinkWidget.tsx +++ b/widget/widgets/OpenLinkWidget.tsx @@ -2,103 +2,43 @@ * Open Link Widget - Demonstrates ui/open-link API */ -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { ExternalLink, Droplets, Wind, MapPin, Loader2 } from "lucide-react"; +import { useState } from "react"; import type { App } from "@modelcontextprotocol/ext-apps/react"; -interface WeatherData { - temperature: number; - humidity: number; - windSpeed: number; - condition: string; -} - interface OpenLinkWidgetProps { app: App; toolInput: { arguments: Record } | null; toolResult: { structuredContent?: Record } | null; } -// Map Open-Meteo weather codes to human-readable conditions -function getWeatherCondition(code: number): string { - if (code === 0) return "Clear sky"; - if (code <= 3) return "Partly cloudy"; - if (code <= 49) return "Foggy"; - if (code <= 59) return "Drizzle"; - if (code <= 69) return "Rain"; - if (code <= 79) return "Snow"; - if (code <= 99) return "Thunderstorm"; - return "Unknown"; -} +const LINKS = [ + { + url: "https://mcpjam.com", + label: "MCPJam", + description: "The Official MCPJam Inspector Website" + }, + { + url: "https://mcpui.dev", + label: "MCP UI", + description: "MCP Apps documentation and resources" + }, + { + url: "https://modelcontextprotocol.io", + label: "Model Context Protocol", + description: "Official MCP documentation and guides" + }, + { + url: "https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx", + label: "SEP-1865 Specification", + description: "Technical specification for MCP Apps" + } +]; -export function OpenLinkWidget({ app, toolInput, toolResult }: OpenLinkWidgetProps) { - const [location, setLocation] = useState(null); - const [weather, setWeather] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); +export function OpenLinkWidget({ app }: OpenLinkWidgetProps) { const [linkOpened, setLinkOpened] = useState(null); - useEffect(() => { - if (toolResult?.structuredContent) { - const data = toolResult.structuredContent as { location?: string }; - if (data.location) setLocation(data.location); - } else if (toolInput?.arguments) { - const args = toolInput.arguments as { location?: string }; - if (args.location) setLocation(args.location); - } - }, [toolResult, toolInput]); - - useEffect(() => { - if (!location) return; - - const fetchWeather = async () => { - setLoading(true); - setError(null); - - try { - // First, geocode the location to get coordinates - const geoResponse = await fetch( - `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1` - ); - const geoData = await geoResponse.json(); - - if (!geoData.results || geoData.results.length === 0) { - setError("Location not found"); - setLoading(false); - return; - } - - const { latitude, longitude } = geoData.results[0]; - - // Then fetch weather data - const weatherResponse = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code` - ); - const weatherData = await weatherResponse.json(); - - if (weatherData.current) { - setWeather({ - temperature: Math.round(weatherData.current.temperature_2m), - humidity: weatherData.current.relative_humidity_2m, - windSpeed: Math.round(weatherData.current.wind_speed_10m), - condition: getWeatherCondition(weatherData.current.weather_code), - }); - } - } catch (err) { - console.error("Failed to fetch weather:", err); - setError("Failed to fetch weather data"); - } finally { - setLoading(false); - } - }; - - fetchWeather(); - }, [location]); - const handleOpenLink = async (url: string, label: string) => { try { - // Open link directly via the SDK await app.sendOpenLink({ url }); setLinkOpened(label); setTimeout(() => setLinkOpened(null), 2000); @@ -107,113 +47,28 @@ export function OpenLinkWidget({ app, toolInput, toolResult }: OpenLinkWidgetPro } }; - if (!location) { - return ( -
-
No location provided
-
- ); - } - - if (loading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
-
{error}
-
- -
-
- ); - } - return ( -
- {/* Location */} -
- - {location} +
+
+

MCP Resources

+

+ Explore these MCP-related resources and documentation +

- {/* Temperature */} - {weather && ( - <> -
-
-
- {weather.temperature} -
-
°C
-
-
- {weather.condition} -
-
- - {/* Stats */} -
-
- - {weather.humidity}% -
-
- - {weather.windSpeed} km/h +
+ {LINKS.map((link) => ( +
handleOpenLink(link.url, link.label)} + > +
+

{link.label}

+

{link.description}

- - )} - - {/* Quick Links */} -
- - + ))}
{/* Success Toast */} diff --git a/widget/widgets/ReadResourceWidget.tsx b/widget/widgets/ReadResourceWidget.tsx deleted file mode 100644 index d731a93..0000000 --- a/widget/widgets/ReadResourceWidget.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Read Resource Widget - Demonstrates resources/read API - */ - -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { BookOpen } from "lucide-react"; -import type { App } from "@modelcontextprotocol/ext-apps/react"; - -interface Resource { - id: string; - title: string; - content: string; -} - -interface AvailableResource { - id: string; - title: string; -} - -interface ReadResourceWidgetProps { - app: App; - toolInput: { arguments: Record } | null; - toolResult: { structuredContent?: Record } | null; -} - -export function ReadResourceWidget({ app, toolResult }: ReadResourceWidgetProps) { - const [availableResources, setAvailableResources] = useState([]); - const [currentResource, setCurrentResource] = useState(null); - const [loading, setLoading] = useState(false); - const [selectedId, setSelectedId] = useState(null); - - useEffect(() => { - if (toolResult?.structuredContent?.availableTips) { - setAvailableResources(toolResult.structuredContent.availableTips as AvailableResource[]); - } - }, [toolResult]); - - const fetchResource = async (resourceId: string) => { - setLoading(true); - setSelectedId(resourceId); - - try { - // Read the resource directly via the SDK - const result = await app.readServerResource({ uri: `tips://${resourceId}` }); - const textContent = result?.contents?.[0]?.text; - - if (textContent) { - const resource = JSON.parse(textContent); - setCurrentResource(resource); - } - } catch (err) { - console.error("Failed to read resource:", err); - } finally { - setLoading(false); - } - }; - - if (availableResources.length === 0) { - return ( -
-
Loading...
-
- ); - } - - return ( -
- {/* Content Display */} -
- {loading && ( -
- Reading... -
- )} - - {!loading && currentResource && ( - <> -
- {currentResource.title} -
-
- {currentResource.content} -
-
- tips://{currentResource.id} -
- - )} - - {!loading && !currentResource && ( -
- Select a topic to read -
- )} -
- - {/* Topic Buttons */} -
- {availableResources.map((resource) => ( - - ))} -
-
- ); -} diff --git a/widget/widgets/SizeChangeWidget.tsx b/widget/widgets/SizeChangeWidget.tsx index 8259a28..2f94ebd 100644 --- a/widget/widgets/SizeChangeWidget.tsx +++ b/widget/widgets/SizeChangeWidget.tsx @@ -1,10 +1,9 @@ /** - * Size Change Widget - Demonstrates ui/size-change API + * Size Change Widget - Demonstrates ui/notifications/size-change API */ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; -import { Loader2 } from "lucide-react"; import type { App } from "@modelcontextprotocol/ext-apps/react"; interface SizeChangeWidgetProps { @@ -19,57 +18,81 @@ const MAX_HEIGHT = 800; export function SizeChangeWidget({ app, toolInput, toolResult }: SizeChangeWidgetProps) { const [height, setHeight] = useState(300); - const [loading, setLoading] = useState(false); const [lastChange, setLastChange] = useState(null); - - // Send initial size on mount - useEffect(() => { - app.sendSizeChange({ height: 300 }); - }, [app]); + const [autoResize, setAutoResize] = useState(false); + const contentRef = useRef(null); useEffect(() => { if (toolInput?.arguments?.height !== undefined) { const inputHeight = toolInput.arguments.height as number; const clampedHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, inputHeight)); setHeight(clampedHeight); - app.sendSizeChange({ height: clampedHeight }); } - }, [toolInput, app]); + }, [toolInput]); useEffect(() => { if (toolResult?.structuredContent?.height !== undefined) { const resultHeight = toolResult.structuredContent.height as number; const clampedHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, resultHeight)); setHeight(clampedHeight); - app.sendSizeChange({ height: clampedHeight }); } - }, [toolResult, app]); + }, [toolResult]); + + // Auto resize effect + useEffect(() => { + if (!autoResize || !contentRef.current) return; + + const updateHeight = () => { + if (contentRef.current) { + const contentHeight = contentRef.current.scrollHeight; + const newHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, contentHeight)); + setHeight(newHeight); + } + }; + + // Initial height calculation + updateHeight(); + + // Set up ResizeObserver to watch for content changes + const resizeObserver = new ResizeObserver(() => { + updateHeight(); + }); + + resizeObserver.observe(contentRef.current); - const handleHeightChange = async (newHeight: number) => { + return () => { + resizeObserver.disconnect(); + }; + }, [autoResize]); + + const handleHeightChange = (newHeight: number) => { // Clamp height to valid range const clampedHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, newHeight)); if (clampedHeight === height) return; - setLoading(true); - try { - // Send size change notification to host - app.sendSizeChange({ height: clampedHeight }); - const change = clampedHeight - height; - setHeight(clampedHeight); - setLastChange(change); - setTimeout(() => setLastChange(null), 1000); - } catch (err) { - console.error("Failed to change size:", err); - } finally { - setLoading(false); - } + const change = clampedHeight - height; + setHeight(clampedHeight); + setLastChange(change); + setTimeout(() => setLastChange(null), 1000); }; return ( -
+
+ {/* Auto Resize Toggle */} +
+
Auto Resize
+ +
+ {/* Main content - centered */} -
+
{/* Height Display */}
@@ -85,7 +108,7 @@ export function SizeChangeWidget({ app, toolInput, toolResult }: SizeChangeWidge )}
- {/* Preset Buttons */} + {/* Preset Buttons - disabled when auto resize is on */}
{PRESET_HEIGHTS.map((presetHeight) => ( ))}
- - {/* Loading indicator */} - {loading && ( -
- - Updating size... -
- )}