@qafka/react-native 2.2.0 → 2.3.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.
@@ -144,8 +144,9 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
144
144
  // grouped with text-mode rows for the same user.
145
145
  endUserId: validatedEndUserId,
146
146
  endUserData: validatedEndUserData,
147
+ onNavigationSuggest,
147
148
  });
148
- const { toolStatus: voiceToolStatus, renderedTools: voiceRenderedTools, transcriptOverrideForTurn, setToolStatusManually, clearRenderedTools, connect: voiceConnect, disconnect: voiceDisconnect, pauseMic: voicePauseMic, resumeMic: voiceResumeMic, isMuted: voiceIsMuted, mute: voiceMute, unmute: voiceUnmute, toggleMute: voiceToggleMute, } = voiceChat;
149
+ const { toolStatus: voiceToolStatus, renderedTools: voiceRenderedTools, transcriptOverrideForTurn, setToolStatusManually, clearRenderedTools, connect: voiceConnect, disconnect: voiceDisconnect, pauseMic: voicePauseMic, resumeMic: voiceResumeMic, isMuted: voiceIsMuted, mute: voiceMute, unmute: voiceUnmute, toggleMute: voiceToggleMute, navigationSuggestion: voiceNavigationSuggestion, acceptNavigationSuggestion: voiceAcceptNavigation, dismissNavigationSuggestion: voiceDismissNavigation, } = voiceChat;
149
150
  const { messages, isTyping, isSending, streamingMessage, toolStatus, flatListRef, handleSend, setMessages, setIsTyping, setStreamingMessage, } = (0, useChatMessages_1.useChatMessages)({
150
151
  enableStreaming,
151
152
  context: currentContext,
@@ -466,7 +467,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
466
467
 
467
468
  {/* Page 1: Voice — VoicePage paints Background edge-to-edge and
468
469
  handles its own internal safe-area padding for orb/transcript. */}
469
- <VoicePage_1.VoicePage state={voiceChat.state} transcript={voiceChat.transcript} userTranscript={voiceChat.userTranscript} transcriptHistory={voiceChat.transcriptHistory} transcriptMode={voiceTranscript} amplitude={voiceChat.amplitude} theme={theme} voiceComponents={voiceComponents} toolStatus={voiceToolStatus} renderedTools={voiceRenderedTools} registeredComponents={customComponents} transcriptOverrideForTurn={transcriptOverrideForTurn} DataChipListComponent={voiceComponents?.dataChipList} isMuted={voiceIsMuted} onToggleMute={voiceToggleMute}/>
470
+ <VoicePage_1.VoicePage state={voiceChat.state} transcript={voiceChat.transcript} userTranscript={voiceChat.userTranscript} transcriptHistory={voiceChat.transcriptHistory} transcriptMode={voiceTranscript} amplitude={voiceChat.amplitude} theme={theme} voiceComponents={voiceComponents} toolStatus={voiceToolStatus} renderedTools={voiceRenderedTools} registeredComponents={customComponents} transcriptOverrideForTurn={transcriptOverrideForTurn} DataChipListComponent={voiceComponents?.dataChipList} isMuted={voiceIsMuted} onToggleMute={voiceToggleMute} navigationSuggestion={voiceNavigationSuggestion} onNavigationAccept={voiceAcceptNavigation} onNavigationDismiss={voiceDismissNavigation} NavigationButtonComponent={NavigationButtonComponent}/>
470
471
  </react_native_1.ScrollView>
471
472
  </react_native_1.View>) : (<ChatPage_1.ChatPage {...chatPageProps}/>);
472
473
  return (<react_native_1.View style={[
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { ComponentType } from 'react';
3
- import type { VoiceComponents, VoiceTranscriptMode, VoiceTranscriptTurn } from './Qafka.types';
3
+ import type { VoiceComponents, VoiceTranscriptMode, VoiceTranscriptTurn, NavigationButtonProps } from './Qafka.types';
4
+ import type { NavigationSuggestion } from '../types/navigation';
4
5
  export type VoiceChatState = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking';
5
6
  interface VoiceToolStatusShape {
6
7
  toolCallId: string;
@@ -43,6 +44,10 @@ interface VoicePageProps {
43
44
  }>;
44
45
  isMuted?: boolean;
45
46
  onToggleMute?: () => void;
47
+ navigationSuggestion?: NavigationSuggestion | null;
48
+ onNavigationAccept?: () => void;
49
+ onNavigationDismiss?: () => void;
50
+ NavigationButtonComponent?: ComponentType<NavigationButtonProps>;
46
51
  }
47
- export declare function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode, amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn, DataChipListComponent, isMuted, onToggleMute, }: VoicePageProps): React.JSX.Element;
52
+ export declare function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode, amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn, DataChipListComponent, isMuted, onToggleMute, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }: VoicePageProps): React.JSX.Element;
48
53
  export {};
@@ -39,6 +39,7 @@ const react_native_1 = require("react-native");
39
39
  const react_native_safe_area_context_1 = require("react-native-safe-area-context");
40
40
  const ToolStatusPill_1 = require("./ToolStatusPill");
41
41
  const DataChipList_1 = require("./DataChipList");
42
+ const NavigationSuggestion_1 = require("./NavigationSuggestion");
42
43
  const { width: SCREEN_WIDTH } = react_native_1.Dimensions.get('window');
43
44
  const STATE_LABELS = {
44
45
  idle: 'Waiting...', connecting: 'Connecting...', listening: 'Listening...',
@@ -395,7 +396,7 @@ function ChatTranscript({ history, liveAiText, state, primaryTextColor, secondar
395
396
  </react_native_1.View>);
396
397
  }
397
398
  // --- Main component ---
398
- function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode = 'centered', amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn = false, DataChipListComponent, isMuted = false, onToggleMute, }) {
399
+ function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode = 'centered', amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn = false, DataChipListComponent, isMuted = false, onToggleMute, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }) {
399
400
  const Indicator = voiceComponents?.VoiceIndicator || DefaultVoiceIndicator;
400
401
  const Background = voiceComponents?.VoiceBackground || DefaultVoiceBackground;
401
402
  const Transcript = voiceComponents?.VoiceTranscript || DefaultVoiceTranscript;
@@ -515,6 +516,8 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
515
516
  {showTranscript ? (<Transcript transcript={transcript} userTranscript={userTranscript} state={state} theme={theme} mode="centered"/>) : null}
516
517
  </react_native_1.View>)}
517
518
 
519
+ {!hasToolUI && navigationSuggestion ? (NavigationButtonComponent ? (<NavigationButtonComponent screenName={navigationSuggestion.screenName} suggestion={navigationSuggestion} onPress={onNavigationAccept ?? (() => { })} theme={theme} style="primary" label={navigationSuggestion.message || `Navigate to ${navigationSuggestion.screenName}`}/>) : (<NavigationSuggestion_1.NavigationSuggestionCard screenName={navigationSuggestion.screenName} message={navigationSuggestion.message} onAccept={onNavigationAccept ?? (() => { })} onDismiss={onNavigationDismiss ?? (() => { })} theme={theme}/>)) : null}
520
+
518
521
  {hasToolUI ? (<react_native_1.ScrollView style={{ flex: 1, width: '100%', alignSelf: 'stretch' }} contentContainerStyle={{
519
522
  paddingHorizontal: 20,
520
523
  paddingTop: 16,
@@ -0,0 +1,19 @@
1
+ import type { NavigationSuggestion } from './types/navigation';
2
+ export interface VoiceNavigationDecision {
3
+ action: 'callback' | 'suggest';
4
+ suggestion: NavigationSuggestion;
5
+ }
6
+ interface IncomingVoiceNavigation {
7
+ screenName: string;
8
+ route: string;
9
+ deeplink?: string | null;
10
+ message?: string;
11
+ trigger?: 'user' | 'ai';
12
+ }
13
+ /**
14
+ * Decide how the voice UI should react to a backend navigation event.
15
+ * trigger='user' → app developer navigates immediately (callback).
16
+ * trigger='ai' (or missing) → SDK shows a suggestion button.
17
+ */
18
+ export declare function decideVoiceNavigation(nav: IncomingVoiceNavigation): VoiceNavigationDecision;
19
+ export {};
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decideVoiceNavigation = decideVoiceNavigation;
4
+ /**
5
+ * Decide how the voice UI should react to a backend navigation event.
6
+ * trigger='user' → app developer navigates immediately (callback).
7
+ * trigger='ai' (or missing) → SDK shows a suggestion button.
8
+ */
9
+ function decideVoiceNavigation(nav) {
10
+ const suggestion = {
11
+ screenName: nav.screenName,
12
+ route: nav.route,
13
+ deeplink: nav.deeplink ?? null,
14
+ ...(nav.message ? { message: nav.message } : {}),
15
+ trigger: nav.trigger === 'user' ? 'user' : 'ai',
16
+ source: 'voice',
17
+ };
18
+ return {
19
+ action: nav.trigger === 'user' ? 'callback' : 'suggest',
20
+ suggestion,
21
+ };
22
+ }
@@ -168,7 +168,11 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
168
168
  }
169
169
  // Handle navigation suggestion
170
170
  if (response.navigationSuggestion && onNavigationSuggest) {
171
- onNavigationSuggest(response.navigationSuggestion);
171
+ onNavigationSuggest({
172
+ ...response.navigationSuggestion,
173
+ trigger: response.navigationSuggestion.trigger ?? 'ai',
174
+ source: 'text',
175
+ });
172
176
  }
173
177
  setIsSending(false);
174
178
  },
@@ -458,7 +462,11 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
458
462
  setMessages((prev) => [...prev, aiMessage]);
459
463
  onResponseReceived?.(response);
460
464
  if (response.navigationSuggestion && onNavigationSuggest) {
461
- onNavigationSuggest(response.navigationSuggestion);
465
+ onNavigationSuggest({
466
+ ...response.navigationSuggestion,
467
+ trigger: response.navigationSuggestion.trigger ?? 'ai',
468
+ source: 'text',
469
+ });
462
470
  }
463
471
  scrollToBottom();
464
472
  setIsTyping(false);
@@ -1,3 +1,4 @@
1
+ import type { NavigationSuggestion } from '../types/navigation';
1
2
  export type VoiceChatState = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking';
2
3
  export interface VoiceToolStatus {
3
4
  toolCallId: string;
@@ -77,6 +78,12 @@ interface UseVoiceChatOptions {
77
78
  */
78
79
  endUserId?: string;
79
80
  endUserData?: Record<string, unknown>;
81
+ /**
82
+ * Callback invoked when the backend suggests navigation.
83
+ * trigger='user' → fires immediately (no button shown).
84
+ * trigger='ai' → fires when the user accepts the suggestion button.
85
+ */
86
+ onNavigationSuggest?: (suggestion: NavigationSuggestion) => void;
80
87
  }
81
88
  interface UseVoiceChatResult {
82
89
  state: VoiceChatState;
@@ -106,5 +113,11 @@ interface UseVoiceChatResult {
106
113
  mute: () => void;
107
114
  unmute: () => void;
108
115
  toggleMute: () => void;
116
+ /** Pending navigation suggestion waiting for user acceptance (trigger='ai'). */
117
+ navigationSuggestion: NavigationSuggestion | null;
118
+ /** Accept the pending suggestion: fires onNavigationSuggest and clears state. */
119
+ acceptNavigationSuggestion: () => void;
120
+ /** Dismiss the pending suggestion without navigating. */
121
+ dismissNavigationSuggestion: () => void;
109
122
  }
110
- export declare function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolSuggested, toolRenderMode, getSessionToken, endUserId, endUserData, }: UseVoiceChatOptions): UseVoiceChatResult;
123
+ export declare function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolSuggested, toolRenderMode, getSessionToken, endUserId, endUserData, onNavigationSuggest, }: UseVoiceChatOptions): UseVoiceChatResult;
@@ -5,7 +5,8 @@ const react_1 = require("react");
5
5
  const react_native_1 = require("react-native");
6
6
  const RealtimeService_1 = require("../services/RealtimeService");
7
7
  const QafkaAudio_1 = require("../native/QafkaAudio");
8
- function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolSuggested, toolRenderMode = 'upsert', getSessionToken, endUserId, endUserData, }) {
8
+ const decide_voice_navigation_1 = require("../decide-voice-navigation");
9
+ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolSuggested, toolRenderMode = 'upsert', getSessionToken, endUserId, endUserData, onNavigationSuggest, }) {
9
10
  const [state, setState] = (0, react_1.useState)('idle');
10
11
  const [transcript, setTranscript] = (0, react_1.useState)('');
11
12
  const [userTranscript, setUserTranscript] = (0, react_1.useState)('');
@@ -14,6 +15,18 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
14
15
  const [toolStatus, setToolStatus] = (0, react_1.useState)(null);
15
16
  const [renderedTools, setRenderedTools] = (0, react_1.useState)([]);
16
17
  const [transcriptOverrideForTurn, setTranscriptOverrideForTurn] = (0, react_1.useState)(false);
18
+ const [navigationSuggestion, setNavigationSuggestion] = (0, react_1.useState)(null);
19
+ const navigationSuggestionRef = (0, react_1.useRef)(null);
20
+ (0, react_1.useEffect)(() => {
21
+ navigationSuggestionRef.current = navigationSuggestion;
22
+ }, [navigationSuggestion]);
23
+ // Set when an explicit (trigger:'user') navigation fires: we navigate
24
+ // immediately but defer closing the voice session until the AI finishes
25
+ // its turn (response.done), so the closing remark isn't cut off.
26
+ const pendingNavDisconnectRef = (0, react_1.useRef)(false);
27
+ // Ref mirror of `disconnect` so handleEvent (useCallback([])) can close the
28
+ // session without going stale.
29
+ const disconnectRef = (0, react_1.useRef)(undefined);
17
30
  const serviceRef = (0, react_1.useRef)(null);
18
31
  // Snapshot of the live AI transcript so `response.done` can push the
19
32
  // finished text into history without depending on stale closure state.
@@ -23,6 +36,10 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
23
36
  (0, react_1.useEffect)(() => {
24
37
  onToolSuggestedRef.current = onToolSuggested;
25
38
  }, [onToolSuggested]);
39
+ const onNavigationSuggestRef = (0, react_1.useRef)(onNavigationSuggest);
40
+ (0, react_1.useEffect)(() => {
41
+ onNavigationSuggestRef.current = onNavigationSuggest;
42
+ }, [onNavigationSuggest]);
26
43
  // handleEvent is wrapped in useCallback([]) so we read the live value
27
44
  // through a ref to avoid stale closures.
28
45
  const toolRenderModeRef = (0, react_1.useRef)(toolRenderMode);
@@ -106,10 +123,18 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
106
123
  transcriptRef.current = '';
107
124
  setTranscript('');
108
125
  setState('listening');
126
+ // Explicit navigation deferred its session close to here, so the AI's
127
+ // closing remark could finish first.
128
+ if (pendingNavDisconnectRef.current) {
129
+ pendingNavDisconnectRef.current = false;
130
+ disconnectRef.current?.();
131
+ }
109
132
  return;
110
133
  }
111
134
  case 'session.closed':
112
135
  setState('idle');
136
+ setNavigationSuggestion(null);
137
+ pendingNavDisconnectRef.current = false;
113
138
  return;
114
139
  case 'error':
115
140
  setState('idle');
@@ -316,6 +341,20 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
316
341
  setToolStatus((s) => (s?.toolCallId === event.toolCallId ? null : s));
317
342
  systemUnmute();
318
343
  return;
344
+ case 'navigation': {
345
+ const decision = (0, decide_voice_navigation_1.decideVoiceNavigation)(event.navigation);
346
+ if (decision.action === 'callback') {
347
+ // Explicit request: navigate now, then close the voice session once the
348
+ // AI finishes speaking (handled in response.done) so the closing remark
349
+ // plays and the session doesn't linger in the background.
350
+ onNavigationSuggestRef.current?.(decision.suggestion);
351
+ pendingNavDisconnectRef.current = true;
352
+ }
353
+ else {
354
+ setNavigationSuggestion(decision.suggestion);
355
+ }
356
+ return;
357
+ }
319
358
  }
320
359
  }, []);
321
360
  const connect = (0, react_1.useCallback)(async () => {
@@ -369,17 +408,35 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
369
408
  setTranscriptHistory([]);
370
409
  transcriptRef.current = '';
371
410
  setAmplitude(0);
411
+ setNavigationSuggestion(null);
412
+ pendingNavDisconnectRef.current = false;
372
413
  // Reset both mute layers so the next session starts clean.
373
414
  userMutedRef.current = false;
374
415
  systemMutedRef.current = false;
375
416
  setIsMuted(false);
376
417
  }, []);
418
+ (0, react_1.useEffect)(() => {
419
+ disconnectRef.current = disconnect;
420
+ }, [disconnect]);
377
421
  const pauseMic = (0, react_1.useCallback)(async () => {
378
422
  await serviceRef.current?.pauseMic();
379
423
  }, []);
380
424
  const resumeMic = (0, react_1.useCallback)(async () => {
381
425
  await serviceRef.current?.resumeMic();
382
426
  }, []);
427
+ const acceptNavigationSuggestion = (0, react_1.useCallback)(() => {
428
+ const current = navigationSuggestionRef.current;
429
+ setNavigationSuggestion(null);
430
+ if (current)
431
+ onNavigationSuggestRef.current?.(current);
432
+ // Consistent with explicit navigation: leaving for a screen ends the
433
+ // voice session. The AI turn is already finished here (button was shown
434
+ // after response.done), so close immediately.
435
+ disconnect();
436
+ }, [disconnect]);
437
+ const dismissNavigationSuggestion = (0, react_1.useCallback)(() => {
438
+ setNavigationSuggestion(null);
439
+ }, []);
383
440
  // Disconnect on app background
384
441
  (0, react_1.useEffect)(() => {
385
442
  const handleAppState = (nextState) => {
@@ -432,5 +489,8 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
432
489
  mute,
433
490
  unmute,
434
491
  toggleMute,
492
+ navigationSuggestion,
493
+ acceptNavigationSuggestion,
494
+ dismissNavigationSuggestion,
435
495
  };
436
496
  }
@@ -31,6 +31,15 @@ export type RealtimeEvent = {
31
31
  } | {
32
32
  type: 'tool_done';
33
33
  toolCallId: string;
34
+ } | {
35
+ type: 'navigation';
36
+ navigation: {
37
+ screenName: string;
38
+ route: string;
39
+ deeplink?: string | null;
40
+ message?: string;
41
+ trigger?: 'user' | 'ai';
42
+ };
34
43
  };
35
44
  export type RealtimeEventHandler = (event: RealtimeEvent) => void;
36
45
  export declare class RealtimeService {
@@ -74,6 +74,10 @@ export interface NavigationSuggestion {
74
74
  confirmed?: boolean;
75
75
  message?: string;
76
76
  reasoning?: string;
77
+ /** Who triggered this: 'user' = explicit request, 'ai' = proactive suggestion. */
78
+ trigger?: 'user' | 'ai';
79
+ /** Which mode produced it. */
80
+ source?: 'text' | 'voice';
77
81
  }
78
82
  /**
79
83
  * Route matching result
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qafka/react-native",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Drop-in AI assistant for React Native: chat, voice, and tool execution with screen-aware navigation.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",