@bytexbyte/nxtlinq-ai-agent-ui-react-development 0.1.1 → 0.1.2

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,143 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import { css } from '@emotion/react';
3
+ import * as React from 'react';
4
+ import { ModelSelector } from './ModelSelector';
5
+ import {
6
+ DRAG_CORNER_EXCLUSION_PX,
7
+ HeaderActions,
8
+ HeaderLoadingIndicator,
9
+ PiiBadge,
10
+ } from './chatBotHeaderParts';
11
+ import { chatHeader, headerTitle } from './styles/isolatedStyles';
12
+
13
+ export type ChatBotHeaderProps = {
14
+ mobileLayout: boolean;
15
+ isDragging: boolean;
16
+ onDragStart: (event: React.PointerEvent<HTMLDivElement>) => void;
17
+ isVoiceMode: boolean;
18
+ isVoiceConnecting: boolean;
19
+ onVoiceToggle: () => void;
20
+ piiDisplayMode: 'plain' | 'redacted';
21
+ isAITLoading: boolean;
22
+ onSettingsClick: () => void;
23
+ onClose: () => void;
24
+ };
25
+
26
+ export const ChatBotHeader: React.FC<ChatBotHeaderProps> = ({
27
+ mobileLayout,
28
+ isDragging,
29
+ onDragStart,
30
+ isVoiceMode,
31
+ isVoiceConnecting,
32
+ onVoiceToggle,
33
+ piiDisplayMode,
34
+ isAITLoading,
35
+ onSettingsClick,
36
+ onClose,
37
+ }) => {
38
+ const actions = (
39
+ <HeaderActions
40
+ isVoiceMode={isVoiceMode}
41
+ isVoiceConnecting={isVoiceConnecting}
42
+ onVoiceToggle={onVoiceToggle}
43
+ onSettingsClick={onSettingsClick}
44
+ onClose={onClose}
45
+ />
46
+ );
47
+
48
+ const metaChips = !isVoiceMode ? (
49
+ <>
50
+ <div css={css`position: relative !important; pointer-events: auto !important;`}>
51
+ <ModelSelector />
52
+ </div>
53
+ {piiDisplayMode === 'redacted' && <PiiBadge />}
54
+ {isAITLoading && <HeaderLoadingIndicator />}
55
+ </>
56
+ ) : null;
57
+
58
+ const dragOverlay = (
59
+ <div
60
+ css={css`
61
+ position: absolute !important;
62
+ left: ${DRAG_CORNER_EXCLUSION_PX}px !important;
63
+ right: ${DRAG_CORNER_EXCLUSION_PX}px !important;
64
+ top: 0 !important;
65
+ bottom: 0 !important;
66
+ z-index: 1 !important;
67
+ cursor: ${isDragging ? 'grabbing' : 'grab'} !important;
68
+ user-select: none !important;
69
+ `}
70
+ onPointerDown={onDragStart}
71
+ title="Drag to move"
72
+ aria-hidden
73
+ />
74
+ );
75
+
76
+ return (
77
+ <div css={[chatHeader, css`
78
+ position: relative !important;
79
+ ${mobileLayout ? css`
80
+ padding: 10px 12px !important;
81
+ flex-direction: column !important;
82
+ align-items: stretch !important;
83
+ gap: 8px !important;
84
+ ` : ''}
85
+ `]}>
86
+ {dragOverlay}
87
+ {mobileLayout ? (
88
+ <div css={css`
89
+ position: relative !important;
90
+ z-index: 2 !important;
91
+ display: flex !important;
92
+ flex-direction: column !important;
93
+ gap: 8px !important;
94
+ width: 100% !important;
95
+ `}>
96
+ <div css={css`
97
+ display: flex !important;
98
+ justify-content: space-between !important;
99
+ align-items: center !important;
100
+ gap: 8px !important;
101
+ pointer-events: none !important;
102
+ `}>
103
+ <h3 css={[headerTitle, css`pointer-events: none !important;`]}>AI Agent</h3>
104
+ {actions}
105
+ </div>
106
+ {metaChips && (
107
+ <div css={css`
108
+ display: flex !important;
109
+ align-items: center !important;
110
+ flex-wrap: wrap !important;
111
+ gap: 8px !important;
112
+ pointer-events: none !important;
113
+ `}>
114
+ {metaChips}
115
+ </div>
116
+ )}
117
+ </div>
118
+ ) : (
119
+ <div css={css`
120
+ position: relative !important;
121
+ z-index: 2 !important;
122
+ display: flex !important;
123
+ justify-content: space-between !important;
124
+ align-items: center !important;
125
+ width: 100% !important;
126
+ pointer-events: none !important;
127
+ `}>
128
+ <div css={css`
129
+ display: flex !important;
130
+ align-items: center !important;
131
+ gap: 10px !important;
132
+ min-width: 0 !important;
133
+ pointer-events: none !important;
134
+ `}>
135
+ <h3 css={headerTitle}>AI Agent</h3>
136
+ {metaChips}
137
+ </div>
138
+ {actions}
139
+ </div>
140
+ )}
141
+ </div>
142
+ );
143
+ };
@@ -3,28 +3,22 @@ import { css } from '@emotion/react';
3
3
  import * as React from 'react';
4
4
  import { useDraggable, useLocalStorage, useResizable, walletTextUtils } from '@bytexbyte/nxtlinq-ai-agent-web-development';
5
5
  import { useChatBot } from '../context/ChatBotContext';
6
- import GraphicEqIcon from '@mui/icons-material/GraphicEq';
6
+ import { ChatBotHeader } from './ChatBotHeader';
7
7
  import { MessageInput } from './MessageInput';
8
8
  import { MessageList } from './MessageList';
9
- import { ModelSelector } from './ModelSelector';
10
9
  import { PermissionForm } from './PermissionForm';
11
10
  import { PresetMessages } from './PresetMessages';
12
11
  import { VoiceModePanel } from './VoiceModePanel';
13
12
  import {
14
- chatHeader,
15
13
  chatWindow,
16
- closeButton,
17
14
  errorToast,
18
15
  floatingButton,
19
- headerButton,
20
- headerTitle,
21
16
  idvBanner,
22
17
  idvBannerText,
23
18
  idvBannerTitle,
24
19
  idvDismissButton,
25
20
  idvVerifyButton,
26
21
  infoToast,
27
- loadingSpinner,
28
22
  modalOverlay,
29
23
  resizeHandleNE,
30
24
  resizeHandleNW,
@@ -36,8 +30,6 @@ import {
36
30
  warningToast
37
31
  } from './styles/isolatedStyles';
38
32
 
39
- /** Header drag band inset so it does not overlap corner resize hit areas */
40
- const DRAG_CORNER_EXCLUSION_PX = 20;
41
33
  const MOBILE_BREAKPOINT = 768;
42
34
  const MOBILE_EDGE_MARGIN = 12;
43
35
  const MOBILE_FAB_POSITION = { right: MOBILE_EDGE_MARGIN, bottom: MOBILE_EDGE_MARGIN } as const;
@@ -716,141 +708,18 @@ export const ChatBotUI: React.FC = () => {
716
708
  </>
717
709
  )}
718
710
 
719
- <div
720
- css={[chatHeader, css`
721
- position: relative !important;
722
- `]}
723
- >
724
- <div
725
- css={css`
726
- position: absolute !important;
727
- left: ${DRAG_CORNER_EXCLUSION_PX}px !important;
728
- right: ${DRAG_CORNER_EXCLUSION_PX}px !important;
729
- top: 0 !important;
730
- bottom: 0 !important;
731
- z-index: 1 !important;
732
- cursor: ${isDragging ? 'grabbing' : 'grab'} !important;
733
- user-select: none !important;
734
- `}
735
- onPointerDown={handleDragStart}
736
- title="Drag to move"
737
- aria-hidden
738
- />
739
- <div
740
- css={css`
741
- position: relative !important;
742
- z-index: 2 !important;
743
- display: flex !important;
744
- justify-content: space-between !important;
745
- align-items: center !important;
746
- width: 100% !important;
747
- pointer-events: none !important;
748
- `}
749
- >
750
- <div css={css`
751
- display: flex !important;
752
- align-items: center !important;
753
- gap: 10px !important;
754
- pointer-events: none !important;
755
- `}>
756
- <h3 css={headerTitle}>
757
- AI Agent
758
- </h3>
759
- {!isVoiceMode && (
760
- <div css={css`
761
- position: relative !important;
762
- pointer-events: auto !important;
763
- `}>
764
- <ModelSelector />
765
- </div>
766
- )}
767
- {!isVoiceMode && piiDisplayMode === 'redacted' && (
768
- <div
769
- css={css`
770
- display: inline-flex !important;
771
- align-items: center !important;
772
- gap: 4px !important;
773
- padding: 2px 8px !important;
774
- background-color: rgba(255, 255, 255, 0.2) !important;
775
- border: 1px solid rgba(255, 255, 255, 0.4) !important;
776
- border-radius: 10px !important;
777
- font-size: 10px !important;
778
- font-weight: 600 !important;
779
- color: #ffffff !important;
780
- white-space: nowrap !important;
781
- line-height: 1.4 !important;
782
- user-select: none !important;
783
- `}
784
- title="PII Protection is active — sensitive data is automatically anonymized before sending to AI"
785
- >
786
- <svg width="10" height="10" viewBox="0 0 24 24" fill="#ffffff" xmlns="http://www.w3.org/2000/svg">
787
- <path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"/>
788
- </svg>
789
- PII Protected
790
- </div>
791
- )}
792
- {isAITLoading && (
793
- <div css={css`
794
- display: flex !important;
795
- align-items: center !important;
796
- gap: 5px !important;
797
- font-size: 12px !important;
798
- opacity: 0.8 !important;
799
- `}>
800
- <div css={loadingSpinner} />
801
- Loading...
802
- </div>
803
- )}
804
- </div>
805
- <div css={css`
806
- display: flex !important;
807
- align-items: center !important;
808
- gap: 10px !important;
809
- pointer-events: auto !important;
810
- `}>
811
- <button
812
- onClick={() => void ((isVoiceMode || isVoiceConnecting) ? exitVoiceMode() : enterVoiceMode())}
813
- css={[headerButton, css`
814
- width: auto !important;
815
- min-width: 82px !important;
816
- padding: 0 10px !important;
817
- gap: 6px !important;
818
- font-size: 12px !important;
819
- font-weight: 600 !important;
820
- line-height: 1 !important;
821
- white-space: nowrap !important;
822
- `]}
823
- title={
824
- isVoiceConnecting
825
- ? 'Cancel voice connection and return to text mode'
826
- : isVoiceMode
827
- ? 'Switch to text mode'
828
- : 'Switch to voice mode'
829
- }
830
- onPointerDown={(e) => e.stopPropagation()}
831
- >
832
- <GraphicEqIcon css={css`font-size: 16px !important; color: #fff !important;`} />
833
- {(isVoiceMode || isVoiceConnecting) ? 'Text Mode' : 'Voice Mode'}
834
- </button>
835
- <button
836
- onClick={handleSettingsClick}
837
- css={headerButton}
838
- title="AIT Settings"
839
- onPointerDown={(e) => e.stopPropagation()}
840
- >
841
- ⚙️
842
- </button>
843
- <button
844
- onClick={handleClose}
845
- css={closeButton}
846
- onPointerDown={(e) => e.stopPropagation()}
847
- title="Minimize"
848
- >
849
-
850
- </button>
851
- </div>
852
- </div>
853
- </div>
711
+ <ChatBotHeader
712
+ mobileLayout={mobileLayout}
713
+ isDragging={isDragging}
714
+ onDragStart={handleDragStart}
715
+ isVoiceMode={isVoiceMode}
716
+ isVoiceConnecting={isVoiceConnecting}
717
+ onVoiceToggle={() => void ((isVoiceMode || isVoiceConnecting) ? exitVoiceMode() : enterVoiceMode())}
718
+ piiDisplayMode={piiDisplayMode}
719
+ isAITLoading={isAITLoading}
720
+ onSettingsClick={handleSettingsClick}
721
+ onClose={handleClose}
722
+ />
854
723
 
855
724
  {showIDVSuggestion && hitAddress && !props.requireWalletIDVVerification && !hasBerifymeToken && !isWalletVerifiedWithBerifyme && (
856
725
  <div
@@ -0,0 +1,115 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import { css } from '@emotion/react';
3
+ import GraphicEqIcon from '@mui/icons-material/GraphicEq';
4
+ import * as React from 'react';
5
+ import { closeButton, headerButton, loadingSpinner } from './styles/isolatedStyles';
6
+
7
+ export const DRAG_CORNER_EXCLUSION_PX = 20;
8
+
9
+ const voiceButtonStyles = css`
10
+ width: auto !important;
11
+ min-width: 82px !important;
12
+ padding: 0 10px !important;
13
+ gap: 6px !important;
14
+ font-size: 12px !important;
15
+ font-weight: 600 !important;
16
+ line-height: 1 !important;
17
+ white-space: nowrap !important;
18
+ `;
19
+
20
+ export const piiBadgeStyles = css`
21
+ display: inline-flex !important;
22
+ align-items: center !important;
23
+ gap: 4px !important;
24
+ padding: 2px 8px !important;
25
+ background-color: rgba(255, 255, 255, 0.2) !important;
26
+ border: 1px solid rgba(255, 255, 255, 0.4) !important;
27
+ border-radius: 10px !important;
28
+ font-size: 10px !important;
29
+ font-weight: 600 !important;
30
+ color: #ffffff !important;
31
+ white-space: nowrap !important;
32
+ line-height: 1.4 !important;
33
+ user-select: none !important;
34
+ `;
35
+
36
+ export const PiiBadge: React.FC = () => (
37
+ <div
38
+ css={piiBadgeStyles}
39
+ title="PII Protection is active — sensitive data is automatically anonymized before sending to AI"
40
+ >
41
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="#ffffff" xmlns="http://www.w3.org/2000/svg">
42
+ <path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
43
+ </svg>
44
+ PII Protected
45
+ </div>
46
+ );
47
+
48
+ type HeaderActionsProps = {
49
+ isVoiceMode: boolean;
50
+ isVoiceConnecting: boolean;
51
+ onVoiceToggle: () => void;
52
+ onSettingsClick: () => void;
53
+ onClose: () => void;
54
+ };
55
+
56
+ export const HeaderActions: React.FC<HeaderActionsProps> = ({
57
+ isVoiceMode,
58
+ isVoiceConnecting,
59
+ onVoiceToggle,
60
+ onSettingsClick,
61
+ onClose,
62
+ }) => (
63
+ <div css={css`
64
+ display: flex !important;
65
+ align-items: center !important;
66
+ gap: 10px !important;
67
+ flex-shrink: 0 !important;
68
+ pointer-events: auto !important;
69
+ `}>
70
+ <button
71
+ onClick={onVoiceToggle}
72
+ css={[headerButton, voiceButtonStyles]}
73
+ title={
74
+ isVoiceConnecting
75
+ ? 'Cancel voice connection and return to text mode'
76
+ : isVoiceMode
77
+ ? 'Switch to text mode'
78
+ : 'Switch to voice mode'
79
+ }
80
+ onPointerDown={(e) => e.stopPropagation()}
81
+ >
82
+ <GraphicEqIcon css={css`font-size: 16px !important; color: #fff !important;`} />
83
+ {(isVoiceMode || isVoiceConnecting) ? 'Text Mode' : 'Voice Mode'}
84
+ </button>
85
+ <button
86
+ onClick={onSettingsClick}
87
+ css={headerButton}
88
+ title="AIT Settings"
89
+ onPointerDown={(e) => e.stopPropagation()}
90
+ >
91
+ ⚙️
92
+ </button>
93
+ <button
94
+ onClick={onClose}
95
+ css={closeButton}
96
+ onPointerDown={(e) => e.stopPropagation()}
97
+ title="Minimize"
98
+ >
99
+
100
+ </button>
101
+ </div>
102
+ );
103
+
104
+ export const HeaderLoadingIndicator: React.FC = () => (
105
+ <div css={css`
106
+ display: flex !important;
107
+ align-items: center !important;
108
+ gap: 5px !important;
109
+ font-size: 12px !important;
110
+ opacity: 0.8 !important;
111
+ `}>
112
+ <div css={loadingSpinner} />
113
+ Loading...
114
+ </div>
115
+ );
@@ -5,11 +5,13 @@ import type {
5
5
  } from '@bytexbyte/nxtlinq-ai-agent-core-development';
6
6
  import { mergeStreamingTranscript } from '@bytexbyte/nxtlinq-ai-agent-core-development';
7
7
  import { useCallback, useRef } from 'react';
8
- import type { InteractionMode } from '../context/AgentAssistantContext';
8
+ import { ensureUserBubbleForVoiceTurn } from './voiceUserBubble';
9
9
 
10
- type VoiceTranscriptAgentApi = {
10
+ type InteractionMode = 'text' | 'voice';
11
+
12
+ export type VoiceTranscriptAgentApi = {
11
13
  getMessages: () => Message[];
12
- setMessages: (messages: Message[]) => void;
14
+ updateMessages: (updater: (prev: Message[]) => Message[]) => void;
13
15
  syncVoiceTurnHistory: (options?: { last?: number }) => Promise<void>;
14
16
  };
15
17
 
@@ -26,6 +28,7 @@ export function useVoiceTranscriptMessages(
26
28
  api: VoiceTranscriptAgentApi,
27
29
  interactionMode: InteractionMode,
28
30
  voiceSessionId: string | null,
31
+ getPendingUserText?: () => string,
29
32
  ) {
30
33
  const streamIdRef = useRef<string | null>(null);
31
34
  const sessionIdRef = useRef(voiceSessionId);
@@ -38,42 +41,48 @@ export function useVoiceTranscriptMessages(
38
41
 
39
42
  const upsertStreaming = useCallback(
40
43
  (text: string) => {
41
- const messages = api.getMessages();
42
- let streamId = streamIdRef.current;
43
- if (!streamId) {
44
- streamId = `${STREAM_PREFIX}${Date.now()}`;
45
- streamIdRef.current = streamId;
46
- }
47
- const idx = messages.findIndex((m) => m.id === streamId);
48
- const partialContent =
49
- idx >= 0
50
- ? mergeStreamingTranscript(messages[idx]?.partialContent ?? '', text)
51
- : text;
52
- const meta = voiceMeta(sessionIdRef.current);
53
- if (idx >= 0) {
54
- api.setMessages(
55
- messages.map((m, i) =>
44
+ api.updateMessages((prev) => {
45
+ let streamId = streamIdRef.current;
46
+ if (!streamId) {
47
+ streamId = `${STREAM_PREFIX}${Date.now()}`;
48
+ streamIdRef.current = streamId;
49
+ }
50
+ const idx = prev.findIndex((m) => m.id === streamId);
51
+ const partialContent =
52
+ idx >= 0
53
+ ? mergeStreamingTranscript(prev[idx]?.partialContent ?? '', text)
54
+ : text;
55
+ const meta = voiceMeta(sessionIdRef.current);
56
+
57
+ if (idx >= 0) {
58
+ return prev.map((m, i) =>
56
59
  i === idx
57
60
  ? { ...m, partialContent, isStreaming: true, metadata: { ...m.metadata, ...meta } }
58
61
  : m,
59
- ),
62
+ );
63
+ }
64
+
65
+ const withUser = ensureUserBubbleForVoiceTurn(
66
+ prev,
67
+ getPendingUserText?.() ?? '',
68
+ undefined,
69
+ meta,
60
70
  );
61
- return;
62
- }
63
- api.setMessages([
64
- ...messages,
65
- {
66
- id: streamId,
67
- role: 'assistant',
68
- content: '',
69
- partialContent,
70
- isStreaming: true,
71
- timestamp: new Date().toISOString(),
72
- metadata: meta,
73
- },
74
- ]);
71
+ return [
72
+ ...withUser,
73
+ {
74
+ id: streamId,
75
+ role: 'assistant' as const,
76
+ content: '',
77
+ partialContent,
78
+ isStreaming: true,
79
+ timestamp: new Date().toISOString(),
80
+ metadata: meta,
81
+ },
82
+ ];
83
+ });
75
84
  },
76
- [api],
85
+ [api, getPendingUserText],
77
86
  );
78
87
 
79
88
  const finalizeAssistant = useCallback(
@@ -82,11 +91,10 @@ export function useVoiceTranscriptMessages(
82
91
  streamIdRef.current = null;
83
92
  if (!trimmed) return;
84
93
 
85
- const messages = api.getMessages();
86
- const streamIdx = messages.findIndex((m) => m.isStreaming && m.role === 'assistant');
87
- if (streamIdx >= 0) {
88
- api.setMessages(
89
- messages.map((m, i) =>
94
+ api.updateMessages((prev) => {
95
+ const streamIdx = prev.findIndex((m) => m.isStreaming && m.role === 'assistant');
96
+ if (streamIdx >= 0) {
97
+ return prev.map((m, i) =>
90
98
  i === streamIdx
91
99
  ? {
92
100
  ...m,
@@ -97,22 +105,21 @@ export function useVoiceTranscriptMessages(
97
105
  metadata: { ...m.metadata, ...voiceMeta(sessionIdRef.current) },
98
106
  }
99
107
  : m,
100
- ),
101
- );
102
- return;
103
- }
104
- const last = messages[messages.length - 1];
105
- if (last?.role === 'assistant' && last.content === trimmed) return;
106
- api.setMessages([
107
- ...messages,
108
- {
109
- id: messageId ?? `voice-asst-${Date.now()}`,
110
- role: 'assistant',
111
- content: trimmed,
112
- timestamp: new Date().toISOString(),
113
- metadata: voiceMeta(sessionIdRef.current),
114
- },
115
- ]);
108
+ );
109
+ }
110
+ const last = prev[prev.length - 1];
111
+ if (last?.role === 'assistant' && last.content === trimmed) return prev;
112
+ return [
113
+ ...prev,
114
+ {
115
+ id: messageId ?? `voice-asst-${Date.now()}`,
116
+ role: 'assistant' as const,
117
+ content: trimmed,
118
+ timestamp: new Date().toISOString(),
119
+ metadata: voiceMeta(sessionIdRef.current),
120
+ },
121
+ ];
122
+ });
116
123
  },
117
124
  [api],
118
125
  );
@@ -122,31 +129,31 @@ export function useVoiceTranscriptMessages(
122
129
  if (!isVoiceUiActive()) return;
123
130
  const text = event.text?.trim() ?? '';
124
131
  if (event.role === 'assistant') {
125
- // Keep one streaming bubble for the whole turn; finalize only in handleDone.
126
132
  if (text) upsertStreaming(text);
127
133
  return;
128
134
  }
129
135
  if (event.role === 'user' && !event.interim && text) {
130
- const messages = api.getMessages();
131
- const last = messages[messages.length - 1];
132
- if (last?.role === 'user' && last.content === text) return;
133
- api.setMessages([
134
- ...messages,
135
- {
136
- id: `voice-user-${Date.now()}`,
137
- role: 'user',
138
- content: text,
139
- timestamp: new Date().toISOString(),
140
- metadata: voiceMeta(sessionIdRef.current),
141
- },
142
- ]);
136
+ api.updateMessages((prev) => {
137
+ const last = prev[prev.length - 1];
138
+ if (last?.role === 'user' && last.content === text) return prev;
139
+ return [
140
+ ...prev,
141
+ {
142
+ id: `voice-user-${Date.now()}`,
143
+ role: 'user' as const,
144
+ content: text,
145
+ timestamp: new Date().toISOString(),
146
+ metadata: voiceMeta(sessionIdRef.current),
147
+ },
148
+ ];
149
+ });
143
150
  }
144
151
  },
145
- [api, finalizeAssistant, isVoiceUiActive, upsertStreaming],
152
+ [api, isVoiceUiActive, upsertStreaming],
146
153
  );
147
154
 
148
155
  const handleDone = useCallback(
149
- (event: VoiceDoneEvent) => {
156
+ (event: VoiceDoneEvent, options?: { pendingUserText?: string }) => {
150
157
  if (!isVoiceUiActive()) return;
151
158
  if (event.guardrailsBlocked || event.billingBlocked || event.error) {
152
159
  streamIdRef.current = null;
@@ -154,6 +161,14 @@ export function useVoiceTranscriptMessages(
154
161
  }
155
162
  const reply = event.replyText?.trim() ?? '';
156
163
  if (reply) {
164
+ api.updateMessages((prev) =>
165
+ ensureUserBubbleForVoiceTurn(
166
+ prev,
167
+ options?.pendingUserText ?? getPendingUserText?.() ?? '',
168
+ event.userMessageId,
169
+ voiceMeta(sessionIdRef.current),
170
+ ),
171
+ );
157
172
  finalizeAssistant(reply, event.assistantMessageId ?? undefined);
158
173
  } else {
159
174
  streamIdRef.current = null;
@@ -162,7 +177,7 @@ export function useVoiceTranscriptMessages(
162
177
  console.warn('[nxtlinq] syncVoiceTurnHistory after voice turn failed', err);
163
178
  });
164
179
  },
165
- [api, finalizeAssistant, isVoiceUiActive],
180
+ [api, finalizeAssistant, getPendingUserText, isVoiceUiActive],
166
181
  );
167
182
 
168
183
  const clearVoiceStream = useCallback(() => {