@ggui-ai/mcp-apps-react-native 0.0.1-placeholder → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ import type { ContentBlock, InvokeTurn } from '@ggui-ai/protocol';
2
+ import type { ConversationMessage } from '../invoke/useInvoke';
3
+ import { extractRenderFromToolResult } from './render';
4
+
5
+ /**
6
+ * A "content group" is one durable-renderable unit from an invoke message:
7
+ * - a contiguous run of text blocks (one text bubble)
8
+ * - one tool_use + its paired tool_result (one card bubble)
9
+ *
10
+ * Integrators persist content groups to their storage and reconstruct
11
+ * ConversationMessages from them on thread reopen.
12
+ */
13
+ export interface ContentGroup {
14
+ /** Deterministic from `${invokeMessageId}-${startBlockIdx}`. Key for idempotency. */
15
+ key: string;
16
+ kind: 'text' | 'card' | 'other';
17
+ authorRole: 'user' | 'agent';
18
+ blocks: ContentBlock[];
19
+ /** For kind='card' only — a frozen GguiSession extracted from the tool_result. */
20
+ cardSnapshot: unknown | null;
21
+ /** Human-readable ~160-char preview (for chat-list lastMessagePreview). */
22
+ textPreview: string;
23
+ }
24
+
25
+ /**
26
+ * Split a finalized invoke ConversationMessage into ContentGroups.
27
+ * Returns [] when the message is still streaming (nothing durable yet).
28
+ */
29
+ export function invokeMessageToContentGroups(message: ConversationMessage): ContentGroup[] {
30
+ if (message.isStreaming) return [];
31
+ const authorRole: 'user' | 'agent' = message.role === 'user' ? 'user' : 'agent';
32
+ const groups: ContentGroup[] = [];
33
+ let i = 0;
34
+ while (i < message.content.length) {
35
+ const b = message.content[i]!;
36
+ if (b.type === 'text') {
37
+ const startIdx = i;
38
+ const textBlocks: ContentBlock[] = [b];
39
+ let j = i + 1;
40
+ while (j < message.content.length && message.content[j]!.type === 'text') {
41
+ textBlocks.push(message.content[j]!);
42
+ j++;
43
+ }
44
+ const preview = textBlocks
45
+ .map((t) => (t.type === 'text' ? t.text : ''))
46
+ .join(' ')
47
+ .slice(0, 160);
48
+ groups.push({
49
+ key: `${message.id}-${startIdx}`,
50
+ kind: 'text',
51
+ authorRole,
52
+ blocks: textBlocks,
53
+ cardSnapshot: null,
54
+ textPreview: preview,
55
+ });
56
+ i = j;
57
+ } else if (b.type === 'tool_use') {
58
+ const paired = message.content.find(
59
+ (x) => x.type === 'tool_result' && x.tool_use_id === b.id,
60
+ );
61
+ const resultBlock = paired ?? null;
62
+ const cardSnapshot = resultBlock ? extractRenderFromToolResult(resultBlock) : null;
63
+ const blocks: ContentBlock[] = resultBlock ? [b, resultBlock] : [b];
64
+ // A render-bearing result IS a card, whatever the tool was named —
65
+ // agent frameworks surface renders under their own tool names
66
+ // (`render_card`, `mcp__cards__update_card`, …) with the payload
67
+ // recognized by extractRenderFromToolResult. The name arm keeps
68
+ // ggui's own render tools folding as cards even when the result
69
+ // block is absent (e.g. still in flight).
70
+ groups.push({
71
+ key: `${message.id}-${i}`,
72
+ kind:
73
+ cardSnapshot !== null || b.name === 'ggui_render' || b.name === 'ggui_update'
74
+ ? 'card'
75
+ : 'other',
76
+ authorRole,
77
+ blocks,
78
+ cardSnapshot,
79
+ textPreview: '[UI card]',
80
+ });
81
+ i++;
82
+ } else {
83
+ // Standalone tool_result (already absorbed) + other kinds — skip.
84
+ i++;
85
+ }
86
+ }
87
+ return groups;
88
+ }
89
+
90
+ /**
91
+ * Inverse: reassemble ConversationMessages from persisted content groups.
92
+ * Groups sharing the same `${invokeMessageId}` prefix collapse back into
93
+ * a single message — used on thread reopen to seed useInvoke.
94
+ */
95
+ export function contentGroupsToConversationMessages(
96
+ groups: ContentGroup[],
97
+ ): ConversationMessage[] {
98
+ const byInvokeMessageId = new Map<string, ConversationMessage>();
99
+ for (const g of groups) {
100
+ const invokeMessageId = g.key.split('-').slice(0, -1).join('-') || g.key;
101
+ const role: 'user' | 'assistant' = g.authorRole === 'user' ? 'user' : 'assistant';
102
+ const existing = byInvokeMessageId.get(invokeMessageId);
103
+ if (existing) {
104
+ existing.content.push(...g.blocks);
105
+ } else {
106
+ byInvokeMessageId.set(invokeMessageId, {
107
+ id: invokeMessageId,
108
+ role,
109
+ content: [...g.blocks],
110
+ isStreaming: false,
111
+ });
112
+ }
113
+ }
114
+ return Array.from(byInvokeMessageId.values());
115
+ }
116
+
117
+ /** Build wire history from in-memory messages, stripping streaming turns. */
118
+ export function conversationMessagesToInvokeHistory(
119
+ messages: ConversationMessage[],
120
+ ): InvokeTurn[] {
121
+ return messages
122
+ .filter((m) => !(m.role === 'assistant' && m.isStreaming))
123
+ .map((m) => ({ role: m.role, content: m.content }));
124
+ }
@@ -0,0 +1,65 @@
1
+ import type { ContentBlock } from '@ggui-ai/protocol';
2
+
3
+ /**
4
+ * Pull a durable render payload out of a `tool_result` block's
5
+ * `content` payload. Tolerant of shapes:
6
+ * - Direct: { id, componentCode, props, ... } (GguiSession itself)
7
+ * - Wrapped: { render: {...} }
8
+ * - Nested: { result: { render: {...} } }
9
+ * - Nested direct: { result: { id, componentCode, ... } }
10
+ * - MCP-Apps bootstrap: { _meta: { ui: { resourceUri } }, ... }
11
+ * (SEP-1865) — a tool result whose `_meta.ui.resourceUri` is set IS
12
+ * a render; the result itself is the durable card payload (it
13
+ * carries `structuredContent` plus the full `_meta.ui` block,
14
+ * including any inlined resource). Same recognition rule as the
15
+ * `useMcpAppsChat` walker — agent framework streams (Claude Agent
16
+ * SDK, ADK, OpenAI Agents) deliver renders in exactly this shape.
17
+ *
18
+ * Returns `null` if no render can be found — caller falls back to
19
+ * placeholder rendering.
20
+ *
21
+ * Post-Phase-B: the legacy `stackItem` wrapper is gone — every tool
22
+ * result carries a single flat `sessionId`. This stays a tolerant
23
+ * heuristic over the remaining shapes (the `render` wrapper key, the
24
+ * direct GguiSession object, the spec-canonical `_meta.ui` bootstrap)
25
+ * rather than pinning one producer's envelope.
26
+ */
27
+ export function extractRenderFromToolResult(block: ContentBlock): unknown | null {
28
+ if (block.type !== 'tool_result') return null;
29
+ const content = block.content as unknown;
30
+ if (typeof content !== 'object' || content === null) return null;
31
+ const rec = content as Record<string, unknown>;
32
+ if (rec.render && typeof rec.render === 'object') return rec.render;
33
+ if (typeof rec.id === 'string' && typeof rec.componentCode === 'string') return rec;
34
+ if (rec._meta !== null && typeof rec._meta === 'object') {
35
+ const ui = (rec._meta as Record<string, unknown>).ui;
36
+ if (ui !== null && typeof ui === 'object') {
37
+ const uri = (ui as Record<string, unknown>).resourceUri;
38
+ if (typeof uri === 'string' && uri.length > 0) return rec;
39
+ }
40
+ }
41
+ for (const v of Object.values(rec)) {
42
+ if (typeof v === 'object' && v !== null) {
43
+ const inner = v as Record<string, unknown>;
44
+ if (inner.render && typeof inner.render === 'object') return inner.render;
45
+ if (typeof inner.id === 'string' && typeof inner.componentCode === 'string') return inner;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /** Same traversal looking for just a sessionId field. */
52
+ export function extractSessionIdFromToolResult(block: ContentBlock): string | null {
53
+ if (block.type !== 'tool_result') return null;
54
+ const content = block.content as unknown;
55
+ if (typeof content !== 'object' || content === null) return null;
56
+ const rec = content as Record<string, unknown>;
57
+ if (typeof rec.sessionId === 'string') return rec.sessionId;
58
+ for (const v of Object.values(rec)) {
59
+ if (typeof v === 'object' && v !== null) {
60
+ const inner = v as Record<string, unknown>;
61
+ if (typeof inner.sessionId === 'string') return inner.sessionId;
62
+ }
63
+ }
64
+ return null;
65
+ }
@@ -0,0 +1,34 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * Collapse rapid source updates into at most one downstream render per
5
+ * animation frame. Use on streaming content where per-delta re-renders
6
+ * would flicker composer focus or tank framerate.
7
+ *
8
+ * Usage:
9
+ * const invoke = useInvoke();
10
+ * const messages = useRafThrottled(invoke.messages);
11
+ */
12
+ export function useRafThrottled<T>(source: T): T {
13
+ const [snapshot, setSnapshot] = useState(source);
14
+ const sourceRef = useRef(source);
15
+ const frameRef = useRef<number | null>(null);
16
+
17
+ sourceRef.current = source;
18
+
19
+ useEffect(() => {
20
+ if (frameRef.current !== null) return;
21
+ frameRef.current = requestAnimationFrame(() => {
22
+ frameRef.current = null;
23
+ setSnapshot(sourceRef.current);
24
+ });
25
+ return () => {
26
+ if (frameRef.current !== null) {
27
+ cancelAnimationFrame(frameRef.current);
28
+ frameRef.current = null;
29
+ }
30
+ };
31
+ }, [source]);
32
+
33
+ return snapshot;
34
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Error Boundary for React Native
3
+ *
4
+ * Catches runtime errors in generated components and displays
5
+ * a native error UI with retry capability.
6
+ */
7
+
8
+ import React, { Component, type ReactNode, type ErrorInfo } from 'react';
9
+ import { View, Text, Pressable, StyleSheet } from 'react-native';
10
+
11
+ export interface ErrorBoundaryProps {
12
+ children: ReactNode;
13
+ onError?: (error: Error, componentStack?: string) => void;
14
+ fallback?: ReactNode;
15
+ }
16
+
17
+ interface ErrorBoundaryState {
18
+ hasError: boolean;
19
+ error: Error | null;
20
+ }
21
+
22
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
23
+ constructor(props: ErrorBoundaryProps) {
24
+ super(props);
25
+ this.state = { hasError: false, error: null };
26
+ }
27
+
28
+ static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
29
+ return { hasError: true, error };
30
+ }
31
+
32
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
33
+ this.props.onError?.(error, errorInfo.componentStack || undefined);
34
+ }
35
+
36
+ reset = (): void => {
37
+ this.setState({ hasError: false, error: null });
38
+ };
39
+
40
+ render(): ReactNode {
41
+ const { children, fallback } = this.props;
42
+ const { hasError, error } = this.state;
43
+
44
+ if (!hasError) {
45
+ return children;
46
+ }
47
+
48
+ if (fallback) {
49
+ return fallback;
50
+ }
51
+
52
+ return (
53
+ <View style={styles.container}>
54
+ <Text style={styles.title}>Something went wrong</Text>
55
+ <Text style={styles.message}>{error?.message || 'An error occurred'}</Text>
56
+ <Pressable style={styles.retryButton} onPress={this.reset}>
57
+ <Text style={styles.retryText}>Try Again</Text>
58
+ </Pressable>
59
+ </View>
60
+ );
61
+ }
62
+ }
63
+
64
+ const styles = StyleSheet.create({
65
+ container: {
66
+ padding: 16,
67
+ backgroundColor: '#fee2e2',
68
+ borderWidth: 1,
69
+ borderColor: '#ef4444',
70
+ borderRadius: 8,
71
+ alignItems: 'center',
72
+ gap: 8,
73
+ },
74
+ title: {
75
+ fontWeight: '700',
76
+ color: '#991b1b',
77
+ fontSize: 16,
78
+ },
79
+ message: {
80
+ fontSize: 14,
81
+ color: '#991b1b',
82
+ textAlign: 'center',
83
+ },
84
+ retryButton: {
85
+ paddingHorizontal: 16,
86
+ paddingVertical: 8,
87
+ backgroundColor: '#dc2626',
88
+ borderRadius: 6,
89
+ marginTop: 4,
90
+ },
91
+ retryText: {
92
+ color: '#ffffff',
93
+ fontWeight: '600',
94
+ fontSize: 14,
95
+ },
96
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * GguiProvider — React Native twin of `@ggui-ai/mcp-apps-react`'s
3
+ * `components/GguiProvider.tsx`.
4
+ *
5
+ * The provider surface mirrors the web copy: permission requests gate
6
+ * on `KNOWN_PERMISSION_NAMES`, `adapterImpls` is the runtime
7
+ * implementation slot only, and the context value shape feeds the
8
+ * same `GguiContext`.
9
+ *
10
+ * Platform delta (every intentional divergence from the web copy):
11
+ *
12
+ * - Interface-context detection reads RN `Dimensions` / `PixelRatio`
13
+ * (with rotation / split-screen change updates) instead of
14
+ * `window`-based `detectInterfaceContext` + resize listener.
15
+ * - RN-only props `reactVersion` / `designSystemUrl` feed the
16
+ * WebView import map.
17
+ * - No `apiBaseUrl` / `appMetadata` props and no app-config fetch —
18
+ * RN hosts pass a pre-fetched `appConfig`; the web provider can
19
+ * fetch it from the platform itself.
20
+ *
21
+ * Guarded by the structural twin gate in `../twin-parity.test.ts`
22
+ * (`DOCUMENTED_DELTA_TWINS`): exported surface must match the web
23
+ * copy.
24
+ */
25
+ import { useState, useCallback, useEffect, useMemo, type ReactNode } from 'react';
26
+ import { Dimensions, PixelRatio } from 'react-native';
27
+ import type { AdapterPermissions, PermissionStatus, InterfaceContext, EndUserIdentity, AppDisplayConfig } from '@ggui-ai/protocol';
28
+ import { KNOWN_PERMISSION_NAMES, UnknownPermissionNameError } from '@ggui-ai/protocol';
29
+ import { GguiContext, type AdapterRegistry, type GguiContextValue } from '../context/GguiContext';
30
+
31
+ const KNOWN_PERMISSION_NAMES_SET: ReadonlySet<string> = new Set(KNOWN_PERMISSION_NAMES);
32
+
33
+ export { useGguiContext, useAdapter, type AdapterRegistry } from '../context/GguiContext';
34
+
35
+ /**
36
+ * Props for the {@link GguiProvider} component.
37
+ */
38
+ export interface GguiProviderProps {
39
+ appId: string;
40
+ wsEndpoint?: string;
41
+ /** Host-provided adapter implementations. Host apps or downstream
42
+ * capability packages augment {@link AdapterRegistry} via
43
+ * declaration merging so each slot is strongly typed. The grant
44
+ * model lives entirely on `clientCapabilities.gadgets[*].permission`
45
+ * — this is just the runtime implementation slot. */
46
+ adapterImpls?: AdapterRegistry;
47
+ /** Override auto-detected interface context */
48
+ interfaceContext?: InterfaceContext;
49
+ /** Auth context surfaced to embedding hosts and renderer surfaces. */
50
+ auth?: {
51
+ currentUser?: EndUserIdentity;
52
+ userId?: string;
53
+ token?: string;
54
+ isAuthenticated: boolean;
55
+ };
56
+ /**
57
+ * Conversation envelope identity. Forwarded by {@link useInvoke} as
58
+ * the `X-Ggui-Host-Session-Id` header so the agent threads multi-turn
59
+ * invokes through its own keyed conversation state. Names the chat
60
+ * thread, not a render.
61
+ */
62
+ hostSessionId?: string;
63
+ /** React version for WebView import map (default: '18.2.0') */
64
+ reactVersion?: string;
65
+ /** Base URL for design system modules in WebView import map */
66
+ designSystemUrl?: string;
67
+ /**
68
+ * Custom handler for permission requests (e.g., camera, microphone).
69
+ *
70
+ * By default, all permissions are granted immediately. Provide a custom
71
+ * `permissionHandler` to integrate with your app's permission flow
72
+ * (e.g., react-native-permissions, expo-permissions, or a custom dialog).
73
+ *
74
+ * @param permission - The Web Permissions API name being requested
75
+ * (e.g., `'camera'`, `'microphone'`, `'geolocation'`). The SDK
76
+ * pre-validates the name against `KNOWN_PERMISSION_NAMES` before
77
+ * invoking this handler, so an unknown name surfaces as a
78
+ * `UnknownPermissionNameError` to the caller without ever
79
+ * reaching the host's permission flow.
80
+ * @returns The resulting permission status
81
+ */
82
+ permissionHandler?: (permission: string) => Promise<PermissionStatus>;
83
+ /**
84
+ * App config (endpointUrl, defaultShellType, etc). Consumed by
85
+ * `useInvoke` for the Streamable Invoke Protocol v1.1 — pass this when
86
+ * you have a pre-fetched `AppDisplayConfig` (e.g. when the host fetches
87
+ * it from an API before mounting). Mirrors the web provider's
88
+ * `appConfig` prop.
89
+ */
90
+ appConfig?: AppDisplayConfig | null;
91
+ children: ReactNode;
92
+ }
93
+
94
+ function detectRNInterfaceContext(): InterfaceContext {
95
+ const { width, height } = Dimensions.get('window');
96
+ // Align breakpoints with getDeviceCategory() in interface-context.ts:
97
+ // width < 768 -> phone
98
+ // width < 1024 -> tablet
99
+ // width >= 1024 -> desktop
100
+ const deviceType: 'phone' | 'tablet' | 'desktop' =
101
+ width < 768 ? 'phone' : width < 1024 ? 'tablet' : 'desktop';
102
+ return {
103
+ viewport: { width, height },
104
+ platform: 'mobile',
105
+ deviceType,
106
+ orientation: width >= height ? 'landscape' : 'portrait',
107
+ devicePixelRatio: PixelRatio.get(),
108
+ touchPrimary: true,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Root provider for the ggui React Native SDK.
114
+ *
115
+ * Wraps the application with shared configuration needed by all ggui hooks
116
+ * and components. Auto-detects interface context from React Native Dimensions
117
+ * and updates on rotation/split-screen changes.
118
+ *
119
+ * @example
120
+ * ```tsx
121
+ * <GguiProvider appId="my-app" wsEndpoint="wss://your-ws-gateway.example/">
122
+ * <App />
123
+ * </GguiProvider>
124
+ * ```
125
+ */
126
+ export function GguiProvider({ appId, wsEndpoint, adapterImpls, interfaceContext: interfaceContextProp, auth, hostSessionId, reactVersion, designSystemUrl, permissionHandler, appConfig, children }: GguiProviderProps) {
127
+ const [adapterPermissions, setAdapterPermissions] = useState<AdapterPermissions>({});
128
+
129
+ // Auto-detect interface context from React Native Dimensions
130
+ const [detectedContext, setDetectedContext] = useState<InterfaceContext>(detectRNInterfaceContext);
131
+
132
+ // Update on dimension changes (rotation, split-screen, etc.)
133
+ useEffect(() => {
134
+ const subscription = Dimensions.addEventListener('change', () => {
135
+ setDetectedContext(detectRNInterfaceContext());
136
+ });
137
+ return () => subscription.remove();
138
+ }, []);
139
+
140
+ const interfaceContext = interfaceContextProp || detectedContext;
141
+
142
+ const requestPermission = useCallback(
143
+ async (permission: string): Promise<PermissionStatus> => {
144
+ // Gate on `KNOWN_PERMISSION_NAMES` (the Web Permissions API
145
+ // enum). With no host-advertised `AdapterType[]` allow-list,
146
+ // the SDK is the last line of defense against typos or
147
+ // unsupported names slipping into the runtime cache.
148
+ if (!KNOWN_PERMISSION_NAMES_SET.has(permission)) {
149
+ throw new UnknownPermissionNameError({ observedName: permission });
150
+ }
151
+ const status: PermissionStatus = permissionHandler
152
+ ? await permissionHandler(permission)
153
+ : 'granted';
154
+ const key = permission as keyof AdapterPermissions;
155
+ setAdapterPermissions((prev) => ({ ...prev, [key]: status }));
156
+ return status;
157
+ },
158
+ [permissionHandler]
159
+ );
160
+
161
+ // `adapterImpls` is the runtime implementation slot ONLY. There is
162
+ // no manifest-level allow-list — grant decisions live on
163
+ // `clientCapabilities.gadgets[*].permission`. Pass all impls
164
+ // verbatim.
165
+ const resolvedAdapterImpls = useMemo<AdapterRegistry>(
166
+ () => (adapterImpls ?? {}) as AdapterRegistry,
167
+ [adapterImpls],
168
+ );
169
+
170
+ const value = useMemo<GguiContextValue>(
171
+ () => ({
172
+ appId,
173
+ wsEndpoint,
174
+ adapterPermissions,
175
+ adapterImpls: resolvedAdapterImpls,
176
+ requestPermission,
177
+ interfaceContext,
178
+ auth,
179
+ hostSessionId,
180
+ reactVersion,
181
+ designSystemUrl,
182
+ appConfig,
183
+ }),
184
+ [appId, wsEndpoint, adapterPermissions, resolvedAdapterImpls, requestPermission, interfaceContext, auth, hostSessionId, reactVersion, designSystemUrl, appConfig]
185
+ );
186
+
187
+ return <GguiContext.Provider value={value}>{children}</GguiContext.Provider>;
188
+ }