@ateam-ai-mobile/ui 0.1.3 → 0.1.5

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/ui",
3
- "version": "0.1.3",
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.",
3
+ "version": "0.1.5",
4
+ "description": "Prebuilt ADAS chat experience for React Native — 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",
7
7
  "scripts": {
@@ -9,8 +9,8 @@
9
9
  "build": "tsup"
10
10
  },
11
11
  "dependencies": {
12
- "@ateam-ai-mobile/core": "0.1.3",
13
- "@ateam-ai-mobile/plugin-runtime": "0.1.3",
12
+ "@ateam-ai-mobile/core": "^0.1.5",
13
+ "@ateam-ai-mobile/plugin-runtime": "^0.1.5",
14
14
  "zustand": "^5.0.0"
15
15
  },
16
16
  "peerDependencies": {
@@ -31,4 +31,4 @@
31
31
  "publishConfig": {
32
32
  "access": "public"
33
33
  }
34
- }
34
+ }
@@ -6,6 +6,7 @@ import { TABLE_BLOCK_OPEN, TABLE_BLOCK_CLOSE, getConfig, ada, borderRadius, font
6
6
  import { AdaOrb, getHaptics } from '@ateam-ai-mobile/plugin-runtime';
7
7
  import JobProgressBar from './JobProgressBar';
8
8
  import UIActionsRow from './UIActionsRow';
9
+ import QuestionChips from './QuestionChips';
9
10
  import TableCardBlock from './TableCardBlock';
10
11
  import { useLightboxStore } from '../stores/lightboxStore';
11
12
  import { getClipboard } from '../providers';
@@ -77,6 +78,10 @@ export default function MessageBubble({ message }: Props) {
77
78
  // answer yet. Render it near-transparent so it floats over the background
78
79
  // instead of looking like a solid message bubble.
79
80
  const st = message.jobProgress?.status;
81
+ // A turn that asked a question with choices (sys.askUser). We take the clean
82
+ // question string from here and render its options as chips — never the raw
83
+ // JSON payload. Not a "pending" mode: answering just sends a normal message.
84
+ const pq = !isUser ? message.jobProgress?.pendingQuestion : undefined;
80
85
  const isProgressOnly =
81
86
  !isUser &&
82
87
  (st === 'running' || st === 'paused') &&
@@ -170,7 +175,9 @@ export default function MessageBubble({ message }: Props) {
170
175
  // No "Working on it…" text — the indicator below is the whole message.
171
176
  null
172
177
  ) : (
173
- renderAssistantContent(message.content, copyText)
178
+ // When the turn asked a question, show its clean question string —
179
+ // never message.content, which may carry the raw JSON payload.
180
+ renderAssistantContent(pq?.question || message.content, copyText)
174
181
  )}
175
182
  {!!copied && (
176
183
  <View style={styles.copiedBadge}>
@@ -186,6 +193,9 @@ export default function MessageBubble({ message }: Props) {
186
193
  {!isUser && message.actions && message.actions.length > 0 && (
187
194
  <UIActionsRow actions={message.actions} />
188
195
  )}
196
+ {pq && (
197
+ <QuestionChips inputType={pq.inputType} options={pq.options} />
198
+ )}
189
199
  {!isProgressOnly && (
190
200
  <Text style={[styles.timeText, isUser && styles.timeTextUser]}>
191
201
  {formatTime(message.timestamp)}
@@ -0,0 +1,76 @@
1
+ // Renders the option chips for an assistant turn that asked a question with
2
+ // choices (sys.askUser). This is NOT a special "pending question" mode — a tap
3
+ // simply calls sendMessage(label), i.e. the user's answer goes back as an
4
+ // ordinary new message (exactly what they'd get by typing it). Kept separate
5
+ // from UIActionsRow (home-action chips) so that mechanism is untouched.
6
+ import React from 'react';
7
+ import { View, Text, Pressable, StyleSheet, Platform } from 'react-native';
8
+ import { ada, fontSize, useChatStore } from '@ateam-ai-mobile/core';
9
+
10
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
11
+
12
+ /** Normalize the options: confirm → Yes/No when no explicit options given. */
13
+ function resolveOptions(inputType: string | undefined, options?: string[]): string[] {
14
+ if (Array.isArray(options) && options.length) return options.filter(o => typeof o === 'string' && o.trim());
15
+ if (inputType === 'confirm') return ['Yes', 'No'];
16
+ return [];
17
+ }
18
+
19
+ export default function QuestionChips({
20
+ inputType,
21
+ options,
22
+ }: {
23
+ inputType?: string;
24
+ options?: string[];
25
+ }) {
26
+ const opts = resolveOptions(inputType, options);
27
+ if (!opts.length) return null;
28
+
29
+ const answer = (label: string) => {
30
+ // The reply is just another message on the same chain — the chain-router
31
+ // folds it into the running task. No dedicated respond endpoint.
32
+ try { useChatStore.getState().sendMessage(label); } catch { /* no-op */ }
33
+ };
34
+
35
+ return (
36
+ <View style={styles.row}>
37
+ {opts.map((label, i) => (
38
+ <Pressable
39
+ key={`${label}-${i}`}
40
+ accessibilityRole="button"
41
+ accessibilityLabel={label}
42
+ onPress={() => answer(label)}
43
+ style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
44
+ >
45
+ <Text style={styles.label} numberOfLines={2}>{label}</Text>
46
+ </Pressable>
47
+ ))}
48
+ </View>
49
+ );
50
+ }
51
+
52
+ const styles = StyleSheet.create({
53
+ row: {
54
+ flexDirection: 'row',
55
+ flexWrap: 'wrap',
56
+ gap: 6,
57
+ marginTop: 10,
58
+ },
59
+ chip: {
60
+ paddingHorizontal: 12,
61
+ paddingVertical: 8,
62
+ backgroundColor: ada.orangeSoft,
63
+ borderColor: ada.orange,
64
+ borderWidth: 1,
65
+ borderRadius: 14,
66
+ },
67
+ chipPressed: {
68
+ backgroundColor: ada.orange,
69
+ },
70
+ label: {
71
+ color: ada.orangeDeep,
72
+ fontSize: fontSize.xs,
73
+ fontWeight: '600',
74
+ fontFamily: fontFam,
75
+ },
76
+ });
@@ -50,7 +50,13 @@ export default function UIActionsRow({ actions }: Props) {
50
50
  // directly — skip navigation (which would try getPlugin() and fail).
51
51
  const surface = useSurfaceStore.getState().manifests[id];
52
52
  if (surface) {
53
- useSurfaceStore.getState().show(id, focus ? { focus } : undefined);
53
+ // Thread the chip's opaque payload (e.g. { skillSlug } for platform:voice)
54
+ // through to the surface as props, alongside focus — same shape the loop /
55
+ // focusUiPlugin path delivers, so chip-tap and loop-open behave identically.
56
+ // GENERIC: we don't interpret payload.data; the surface Component reads
57
+ // what it understands.
58
+ const props = { ...(focus ? { focus } : {}), ...(a.payload?.data || {}) };
59
+ useSurfaceStore.getState().show(id, Object.keys(props).length ? props : undefined);
54
60
  return;
55
61
  }
56
62
 
@@ -23,6 +23,8 @@ import useVoiceCall from './useVoiceCall';
23
23
  interface Props {
24
24
  visible: boolean;
25
25
  onClose: () => void;
26
+ /** Optional skill to hand the mic to on connect (voice /session → takeMic). */
27
+ skillSlug?: string;
26
28
  }
27
29
 
28
30
  const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
@@ -88,12 +90,12 @@ function HighlightedText({
88
90
  );
89
91
  }
90
92
 
91
- export default function VoiceCallSheet({ visible, onClose }: Props) {
93
+ export default function VoiceCallSheet({ visible, onClose, skillSlug }: Props) {
92
94
  const {
93
95
  callState, aiPartial, userPartial, transcript, error, muted,
94
96
  startCall, stopCall, toggleMute, handleWebViewMessage, getWebViewSource, webViewRef,
95
97
  injectedTrackHook,
96
- } = useVoiceCall();
98
+ } = useVoiceCall({ skillSlug });
97
99
 
98
100
  const scrollRef = useRef<ScrollView>(null);
99
101
 
@@ -7,7 +7,36 @@
7
7
  * without it. Identity (token/tenant/actor) comes from the host-owned config.
8
8
  */
9
9
  import { useState, useRef, useCallback } from 'react';
10
- import { getVoiceApiUrl, getToken, getTenant, getUser } from '@ateam-ai-mobile/core';
10
+ import { getVoiceApiUrl, getToken, getTenant, getUser, refreshToken } from '@ateam-ai-mobile/core';
11
+
12
+ /**
13
+ * The voice WebView bakes the JWT into its URL once at mount and never
14
+ * refreshes it (unlike the SDK REST client, which self-heals on 401). So a
15
+ * token that has gone stale by the time the user taps the mic is handed to
16
+ * voice-backend and rejected by Core /api/auth/me ("Invalid or expired
17
+ * token"). Guard: if the current token is expired or within `SKEW_MS` of
18
+ * expiry, refresh it BEFORE the WebView mounts so it gets a fresh token.
19
+ * Cheap in the common case — we only pay the round-trip when actually near
20
+ * expiry (decoded locally, no network unless needed).
21
+ */
22
+ const TOKEN_REFRESH_SKEW_MS = 90_000; // refresh if <90s of life left (or expired)
23
+ function tokenNeedsRefresh(token: string | null): boolean {
24
+ if (!token) return true;
25
+ try {
26
+ const payloadB64 = token.split('.')[1];
27
+ const padded = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
28
+ // Hermes has atob; Buffer fallback for older runtimes (mirrors
29
+ // apps/mobile authStore.decodeJwtPayload).
30
+ const json = typeof atob === 'function'
31
+ ? atob(padded)
32
+ : Buffer.from(padded, 'base64').toString('utf8');
33
+ const exp = JSON.parse(json)?.exp;
34
+ if (typeof exp !== 'number') return false;
35
+ return Date.now() >= exp * 1000 - TOKEN_REFRESH_SKEW_MS;
36
+ } catch {
37
+ return false; // can't decode → let the server decide, don't block the call
38
+ }
39
+ }
11
40
 
12
41
  export interface TranscriptEntry {
13
42
  role: 'user' | 'ai' | 'tool';
@@ -113,7 +142,7 @@ const buildMuteScript = (muted: boolean) => `
113
142
  })(${muted}); true;
114
143
  `;
115
144
 
116
- export default function useVoiceCall() {
145
+ export default function useVoiceCall({ skillSlug }: { skillSlug?: string } = {}) {
117
146
  const [callState, setCallState] = useState<CallState>('idle');
118
147
  const [sessionId, setSessionId] = useState<string | null>(null);
119
148
  const [error, setError] = useState<string | null>(null);
@@ -156,12 +185,25 @@ export default function useVoiceCall() {
156
185
  } catch {}
157
186
  }, []);
158
187
 
159
- const startCall = useCallback(() => {
160
- setCallState('connecting');
188
+ const startCall = useCallback(async () => {
161
189
  setError(null);
162
190
  setTranscript([]);
163
191
  setAiPartial('');
164
192
  setUserPartial('');
193
+ // Ensure the WebView is handed a FRESH token BEFORE it mounts (it never
194
+ // refreshes once mounted, and the WebView mounts as soon as callState
195
+ // leaves 'idle'). Only pays a round-trip when the token is expired or
196
+ // near-expiry; otherwise instant. Flip to 'connecting' only after, so
197
+ // getWebViewSource() reads the refreshed token.
198
+ try {
199
+ if (tokenNeedsRefresh(getToken())) {
200
+ await refreshToken();
201
+ }
202
+ } catch {
203
+ // Refresh failed — proceed with whatever token we have; voice-backend
204
+ // will surface a clean auth error rather than us blocking the call.
205
+ }
206
+ setCallState('connecting');
165
207
  }, []);
166
208
 
167
209
  const stopCall = useCallback(() => {
@@ -187,10 +229,13 @@ export default function useVoiceCall() {
187
229
  const token = getToken();
188
230
  const tenant = getTenant();
189
231
  const actorId = getUser()?.actorId || '';
232
+ // skillSlug (when opened for a specific skill) is read by the voice mini-app
233
+ // and posted to /session, which turns it into takeMic (E1 held-mic entry).
234
+ const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : '';
190
235
  return {
191
- uri: `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}`,
236
+ uri: `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}`,
192
237
  };
193
- }, []);
238
+ }, [skillSlug]);
194
239
 
195
240
  return {
196
241
  callState, sessionId, error, transcript, aiPartial, userPartial, muted,