@ateam-ai-mobile/ui 0.1.0 → 0.1.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Prebuilt ADAS chat experience for React Native \u2014 full-screen chat, chips, widget views. Built on @ateam-ai-mobile/core + @ateam-ai-mobile/plugin-runtime.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,8 +9,8 @@
9
9
  "build": "tsup"
10
10
  },
11
11
  "dependencies": {
12
- "@ateam-ai-mobile/core": "0.1.0",
13
- "@ateam-ai-mobile/plugin-runtime": "0.1.0",
12
+ "@ateam-ai-mobile/core": "0.1.1",
13
+ "@ateam-ai-mobile/plugin-runtime": "0.1.1",
14
14
  "zustand": "^5.0.0"
15
15
  },
16
16
  "peerDependencies": {
@@ -28,6 +28,7 @@ import MessageBubble, { DaySeparator } from './chat/MessageBubble';
28
28
  import TypingIndicator from './chat/TypingIndicator';
29
29
  import ChatInput from './chat/ChatInput';
30
30
  import Lightbox from './Lightbox';
31
+ import WidgetHost from './plugin/WidgetHost';
31
32
 
32
33
  export interface AdasChatScreenProps {
33
34
  /** Called when the user taps the call button; omit to hide it. */
@@ -152,6 +153,9 @@ export default function AdasChatScreen({
152
153
  placeholder={placeholder}
153
154
  />
154
155
  {withLightbox && <Lightbox />}
156
+ {/* Built-in widget presenter: chips without a host navigator and
157
+ agent-driven focus-plugin events open widgets here — zero wiring. */}
158
+ <WidgetHost />
155
159
  </KeyboardAvoidingView>
156
160
  );
157
161
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * TenantPicker — optional prebuilt team switcher.
3
+ *
4
+ * Lists the user's REAL tenant memberships via core's listMyTenants() and
5
+ * calls onSelect when one is tapped. The host owns what "switching" means
6
+ * (mint a new tenant-scoped JWT, then re-init) — this component only picks.
7
+ * Single-tenant hosts simply never render it.
8
+ */
9
+ import { useEffect, useState } from 'react';
10
+ import { View, Text, Pressable, ActivityIndicator, StyleSheet, Platform } from 'react-native';
11
+ import { ada, fontSize, borderRadius, listMyTenants, getTenant } from '@ateam-ai-mobile/core';
12
+ import type { TenantMembership } from '@ateam-ai-mobile/core';
13
+
14
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
15
+
16
+ export interface TenantPickerProps {
17
+ /** Called with the picked tenant. Host mints a JWT for it and re-inits. */
18
+ onSelect: (tenant: TenantMembership) => void;
19
+ /** Highlight the active tenant. Defaults to the SDK config's tenant. */
20
+ currentTenantId?: string;
21
+ }
22
+
23
+ export default function TenantPicker({ onSelect, currentTenantId }: TenantPickerProps) {
24
+ const [tenants, setTenants] = useState<TenantMembership[] | null>(null);
25
+ const [error, setError] = useState<string | null>(null);
26
+ const active = currentTenantId ?? getTenant();
27
+
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ listMyTenants()
31
+ .then(list => { if (!cancelled) setTenants(list); })
32
+ .catch(e => { if (!cancelled) setError(e?.message || 'Failed to load teams'); });
33
+ return () => { cancelled = true; };
34
+ }, []);
35
+
36
+ if (error) return <Text style={styles.error}>{error}</Text>;
37
+ if (!tenants) {
38
+ return (
39
+ <View style={styles.loading}>
40
+ <ActivityIndicator color={ada.orange} />
41
+ </View>
42
+ );
43
+ }
44
+ if (tenants.length === 0) return <Text style={styles.empty}>No teams found for this account.</Text>;
45
+
46
+ return (
47
+ <View style={styles.list}>
48
+ {tenants.map(t => {
49
+ const isActive = t.id === active;
50
+ return (
51
+ <Pressable
52
+ key={t.id}
53
+ accessibilityRole="button"
54
+ accessibilityLabel={`Team ${t.name}${isActive ? ', current' : ''}`}
55
+ onPress={() => onSelect(t)}
56
+ style={[styles.row, isActive && styles.rowActive]}
57
+ >
58
+ <View style={styles.dot} />
59
+ <View style={styles.rowText}>
60
+ <Text style={styles.name}>{t.name}</Text>
61
+ <Text style={styles.slug}>{t.id}</Text>
62
+ </View>
63
+ {isActive && <Text style={styles.check}>✓</Text>}
64
+ </Pressable>
65
+ );
66
+ })}
67
+ </View>
68
+ );
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ list: {
73
+ gap: 10,
74
+ },
75
+ row: {
76
+ flexDirection: 'row',
77
+ alignItems: 'center',
78
+ gap: 12,
79
+ backgroundColor: ada.paperSoft,
80
+ borderRadius: borderRadius.lg,
81
+ borderWidth: 1,
82
+ borderColor: 'transparent',
83
+ paddingHorizontal: 16,
84
+ paddingVertical: 14,
85
+ },
86
+ rowActive: {
87
+ backgroundColor: ada.orangeSoft,
88
+ borderColor: ada.orange,
89
+ },
90
+ dot: {
91
+ width: 10,
92
+ height: 10,
93
+ borderRadius: 5,
94
+ backgroundColor: ada.orange,
95
+ },
96
+ rowText: {
97
+ flex: 1,
98
+ },
99
+ name: {
100
+ color: ada.ink,
101
+ fontSize: fontSize.lg,
102
+ fontWeight: '600',
103
+ fontFamily: fontFam,
104
+ },
105
+ slug: {
106
+ color: ada.inkMuted,
107
+ fontSize: fontSize.sm,
108
+ fontFamily: 'monospace',
109
+ },
110
+ check: {
111
+ color: ada.orangeDeep,
112
+ fontSize: fontSize.lg,
113
+ fontWeight: '700',
114
+ },
115
+ loading: {
116
+ paddingVertical: 24,
117
+ alignItems: 'center',
118
+ },
119
+ empty: {
120
+ color: ada.inkMuted,
121
+ fontSize: fontSize.md,
122
+ textAlign: 'center',
123
+ paddingVertical: 16,
124
+ },
125
+ error: {
126
+ color: '#C15545',
127
+ fontSize: fontSize.md,
128
+ textAlign: 'center',
129
+ paddingVertical: 16,
130
+ },
131
+ });
@@ -2,15 +2,18 @@
2
2
  // Payload comes from `message.actions[]` produced by the polisher and
3
3
  // validated by Core's `cp.uiActions_api`.
4
4
  //
5
- // Navigation is host-owned: chips targeting a registered surface mount it
6
- // directly via the surface store; anything else goes through the navigator
7
- // registered with setUIActionNavigator() (no router imports here).
5
+ // Chips targeting a registered surface mount it directly via the surface
6
+ // store. Everything else goes through the navigator registered with
7
+ // setUIActionNavigator() when the host provided one (no router imports
8
+ // here), or falls back to the built-in WidgetHost presenter — so chips open
9
+ // widgets with zero host wiring.
8
10
 
9
11
  import { View, Text, Pressable, StyleSheet, Platform } from 'react-native';
10
12
  import { ada, fontSize } from '@ateam-ai-mobile/core';
11
13
  import type { UIAction } from '@ateam-ai-mobile/core';
12
14
  import { useSurfaceStore, getHaptics } from '@ateam-ai-mobile/plugin-runtime';
13
15
  import { getUIActionNavigator } from '../providers';
16
+ import { openWidget } from '../plugin/WidgetHost';
14
17
 
15
18
  const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
16
19
 
@@ -66,11 +69,13 @@ export default function UIActionsRow({ actions }: Props) {
66
69
  }
67
70
 
68
71
  const navigate = getUIActionNavigator();
69
- if (!navigate) {
70
- console.warn('[UIActionsRow] no navigator registered — call setUIActionNavigator() in the host to handle plugin chips');
72
+ if (navigate) {
73
+ navigate({ pluginId: id, params });
71
74
  return;
72
75
  }
73
- navigate({ pluginId: id, params });
76
+ // No host navigator — present via the built-in WidgetHost (mounted by
77
+ // AdasChatScreen). Params ride along as engine props.
78
+ openWidget(id, Object.keys(params).length ? params : undefined);
74
79
  };
75
80
 
76
81
  return (
package/src/index.ts CHANGED
@@ -20,19 +20,33 @@ export { default as UIActionsRow } from './chat/UIActionsRow';
20
20
  // Plugin rendering (native bundles + iframe fallback + surface wrapper)
21
21
  export { default as Plugin } from './plugin/Plugin';
22
22
  export { default as PluginSurface } from './plugin/PluginSurface';
23
+ // Zero-config widget presenter: chips + agent focus-plugin → open widgets.
24
+ // AdasChatScreen mounts one automatically; custom layouts render <WidgetHost/>
25
+ // once near the root and/or call openWidget(pluginId, props) imperatively.
26
+ export { default as WidgetHost, openWidget, useWidgetHostStore } from './plugin/WidgetHost';
23
27
  export { default as EmbeddedPluginWebView } from './plugin/EmbeddedPluginWebView';
24
28
  export { usePluginBridge, buildPluginBridgeJS } from './plugin/usePluginBridge';
25
29
 
26
30
  // Widgets
27
31
  export { default as WidgetWebView } from './widgets/WidgetWebView';
28
32
 
33
+ // Team switcher (optional — single-tenant hosts never render it)
34
+ export { default as TenantPicker } from './TenantPicker';
35
+ export type { TenantPickerProps } from './TenantPicker';
36
+
29
37
  // Image lightbox
30
38
  export { default as Lightbox } from './Lightbox';
31
39
  export { useLightboxStore } from './stores/lightboxStore';
32
40
 
41
+ // Voice (requires `voiceUrl` in the initAdas() config)
42
+ export { default as VoiceCallSheet } from './voice/VoiceCallSheet';
43
+ export { default as useVoiceCall } from './voice/useVoiceCall';
44
+ export type { TranscriptEntry, CallState } from './voice/useVoiceCall';
45
+
33
46
  // Host integration
34
47
  export {
35
48
  setClipboardProvider,
36
49
  setUIActionNavigator,
50
+ setPluginWebViewExtraHosts,
37
51
  } from './providers';
38
52
  export type { ClipboardProvider, UIActionRoute } from './providers';
@@ -1,9 +1,10 @@
1
1
  import { useState, useMemo, useCallback } from 'react';
2
2
  import { View, Text, ActivityIndicator, StyleSheet, Platform } from 'react-native';
3
3
  import { WebView } from 'react-native-webview';
4
- import { getConfig, getCoreApiUrl, getTenant, getToken, ada } from '@ateam-ai-mobile/core';
4
+ import { getConfig, getCoreApiUrl, getVoiceApiUrl, getTenant, getToken, ada } from '@ateam-ai-mobile/core';
5
5
  import { resolvePluginUrl, type PlatformPluginManifest as PluginManifest } from '@ateam-ai-mobile/plugin-runtime';
6
6
  import { usePluginBridge, buildPluginBridgeJS } from './usePluginBridge';
7
+ import { getPluginWebViewExtraHosts } from '../providers';
7
8
 
8
9
  interface Props {
9
10
  pluginId: string;
@@ -29,13 +30,18 @@ export default function EmbeddedPluginWebView({ pluginId, manifest, commandBuffe
29
30
  const url = resolvePluginUrl(manifest, tenant || undefined);
30
31
  const bridgeJS = buildPluginBridgeJS(token || '', tenant || '');
31
32
 
32
- // Hosts the plugin may navigate to — derived from the SDK config so the
33
- // whitelist always matches the backend the host pointed us at.
34
- const allowedHosts = useMemo(() => {
33
+ // Hosts the plugin may navigate to — derived from the SDK config plus any
34
+ // host-registered extras (e.g. a relay service). Resolved LIVE on every
35
+ // read, never frozen at mount: config URLs can change after hydration and a
36
+ // stale whitelist silently bounces plugin URLs out to the system browser
37
+ // (a documented regression in the reference app).
38
+ const computeAllowedHosts = useCallback((): Set<string> => {
35
39
  const hosts = new Set<string>();
36
40
  const urls = [getCoreApiUrl()];
37
- const voice = getConfig().voiceUrl;
38
- if (voice) urls.push(voice);
41
+ // voiceUrl is optional and may be a getter — resolve via getVoiceApiUrl()
42
+ // (which throws when unset, so gate on the raw config first).
43
+ if (getConfig().voiceUrl) urls.push(getVoiceApiUrl());
44
+ try { urls.push(...(getPluginWebViewExtraHosts()?.() || [])); } catch { /* host getter threw */ }
39
45
  for (const u of urls) {
40
46
  try { hosts.add(new URL(u).hostname); } catch { /* ignore bad URL */ }
41
47
  }
@@ -49,12 +55,12 @@ export default function EmbeddedPluginWebView({ pluginId, manifest, commandBuffe
49
55
  try { parsed = new URL(rawUrl); } catch { return false; }
50
56
  if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false;
51
57
  if (parsed.protocol === 'http:') return false; // belt-and-suspenders with mixedContentMode=never
52
- return allowedHosts.has(parsed.hostname);
53
- }, [allowedHosts]);
58
+ return computeAllowedHosts().has(parsed.hostname);
59
+ }, [computeAllowedHosts]);
54
60
 
55
61
  const originWhitelist = useMemo(
56
- () => Array.from(allowedHosts).map(h => `https://${h}`),
57
- [allowedHosts],
62
+ () => Array.from(computeAllowedHosts()).map(h => `https://${h}`),
63
+ [computeAllowedHosts],
58
64
  );
59
65
 
60
66
  if (error) {
@@ -15,6 +15,7 @@ import {
15
15
  useNativeBridge,
16
16
  usePluginTheme,
17
17
  getPluginNative,
18
+ gatePluginNative,
18
19
  type PluginContext,
19
20
  } from '@ateam-ai-mobile/plugin-runtime';
20
21
  import EmbeddedPluginWebView from './EmbeddedPluginWebView';
@@ -84,7 +85,11 @@ export default function Plugin({ id, manifest, commandBufferRef, props: enginePr
84
85
 
85
86
  // Wire up bridge, capabilities, theme
86
87
  const bridge = useNativeBridge(id, connectorId, commandBufferRef);
87
- const native = getPluginNative();
88
+ // Per-plugin capability gate: undeclared native APIs become warn-and-ignore
89
+ // proxies. Memoized so the plugin Component doesn't re-render on a fresh
90
+ // object every pass (useApi effects key off prop identity).
91
+ const declaredCaps = manifest?.native;
92
+ const native = useMemo(() => gatePluginNative(getPluginNative(), declaredCaps), [declaredCaps]);
88
93
  const theme = usePluginTheme();
89
94
 
90
95
  // Build context from the host-owned identity
@@ -93,8 +98,8 @@ export default function Plugin({ id, manifest, commandBufferRef, props: enginePr
93
98
  tenant: getTenant(),
94
99
  pluginId: id,
95
100
  connectorId,
96
- user: user ? { id: user.actorId || '', name: user.name || '', roles: [] } : undefined,
97
- }), [id, connectorId, user?.actorId, user?.name]);
101
+ user: user ? { id: user.actorId || '', name: user.name || '', roles: user.roles || [] } : undefined,
102
+ }), [id, connectorId, user?.actorId, user?.name, user?.roles]);
98
103
 
99
104
  // Fallback: connector declared an RN component but didn't ship the bundle.
100
105
  // When the manifest also has an iframeUrl (adaptive mode), render the
@@ -0,0 +1,81 @@
1
+ /**
2
+ * WidgetHost — zero-config widget presenter.
3
+ *
4
+ * The glue that makes chips and the agent able to open widgets without any
5
+ * host wiring:
6
+ *
7
+ * - `openWidget(pluginId, opts)` — imperative open from anywhere. Surfaces
8
+ * registered in the surface store (host surfaces, platform-declared
9
+ * surfaces) mount through the surface registry; everything else is
10
+ * presented here as a fullscreen PluginSurface (manifest fetched lazily).
11
+ * - Chip taps: UIActionsRow falls back to openWidget() when the host hasn't
12
+ * registered a custom navigator via setUIActionNavigator().
13
+ * - Server/agent proactive open: listens for the chat stream's
14
+ * `focus-plugin` event (sys.focusUiPlugin(pluginId, props) on the engine
15
+ * side) and presents the widget, forwarding the engine props.
16
+ *
17
+ * <AdasChatScreen /> mounts a WidgetHost automatically — drop-in hosts get
18
+ * the full chips→widgets and agent→widgets experience for free. Custom
19
+ * layouts render <WidgetHost /> once near the app root (mounting it twice is
20
+ * safe: both instances read the same store, last-closed wins).
21
+ */
22
+ import { useEffect } from 'react';
23
+ import { create } from 'zustand';
24
+ import { pluginCommandEmitter } from '@ateam-ai-mobile/core';
25
+ import { useSurfaceStore } from '@ateam-ai-mobile/plugin-runtime';
26
+ import PluginSurface from './PluginSurface';
27
+
28
+ interface WidgetHostState {
29
+ pluginId: string | null;
30
+ /** Engine/chip-supplied props forwarded to the plugin as PluginProps.props. */
31
+ props?: any;
32
+ open: (pluginId: string, props?: any) => void;
33
+ close: () => void;
34
+ }
35
+
36
+ export const useWidgetHostStore = create<WidgetHostState>((set) => ({
37
+ pluginId: null,
38
+ props: undefined,
39
+ open: (pluginId, props) => set({ pluginId, props }),
40
+ close: () => set({ pluginId: null, props: undefined }),
41
+ }));
42
+
43
+ /**
44
+ * Open a widget by plugin id. Host surfaces registered in the surface store
45
+ * take priority (they own their chrome); everything else is presented as a
46
+ * fullscreen PluginSurface by the mounted <WidgetHost />.
47
+ */
48
+ export function openWidget(pluginId: string, props?: any): void {
49
+ const surfaces = useSurfaceStore.getState();
50
+ if (surfaces.manifests[pluginId]) {
51
+ surfaces.show(pluginId, props);
52
+ return;
53
+ }
54
+ useWidgetHostStore.getState().open(pluginId, props);
55
+ }
56
+
57
+ export default function WidgetHost() {
58
+ const pluginId = useWidgetHostStore(s => s.pluginId);
59
+ const props = useWidgetHostStore(s => s.props);
60
+ const close = useWidgetHostStore(s => s.close);
61
+
62
+ // Agent-driven proactive open: sys.focusUiPlugin(pluginId, props) flows
63
+ // through the chat stream → core emits 'focus-plugin' → present it.
64
+ useEffect(() => {
65
+ return pluginCommandEmitter.on('focus-plugin', (data: any) => {
66
+ if (data?.pluginId) openWidget(String(data.pluginId), data.props);
67
+ });
68
+ }, []);
69
+
70
+ if (!pluginId) return null;
71
+
72
+ return (
73
+ <PluginSurface
74
+ key={pluginId}
75
+ pluginId={pluginId}
76
+ surface={{ type: 'fullscreen' }}
77
+ dismiss={close}
78
+ props={props}
79
+ />
80
+ );
81
+ }
package/src/providers.ts CHANGED
@@ -44,3 +44,20 @@ export function setUIActionNavigator(navigate: UIActionNavigator | null): void {
44
44
  export function getUIActionNavigator(): UIActionNavigator | null {
45
45
  return _navigator;
46
46
  }
47
+
48
+ // ── Plugin WebView extra allowed hosts ─────────────────────────────────────
49
+ // The plugin WebView allow-list is derived from the SDK config (core + voice
50
+ // URLs). Hosts with additional trusted services plugins navigate to (e.g. a
51
+ // relay) register a getter returning extra URLs; resolved live per check.
52
+
53
+ type ExtraHostsGetter = () => string[];
54
+
55
+ let _extraHosts: ExtraHostsGetter | null = null;
56
+
57
+ export function setPluginWebViewExtraHosts(getter: ExtraHostsGetter | null): void {
58
+ _extraHosts = getter;
59
+ }
60
+
61
+ export function getPluginWebViewExtraHosts(): ExtraHostsGetter | null {
62
+ return _extraHosts;
63
+ }