fluxy-bot 0.8.9 → 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.
Files changed (106) hide show
  1. package/README.md +53 -84
  2. package/bin/cli.js +1717 -6
  3. package/components.json +18 -18
  4. package/package.json +98 -109
  5. package/postcss.config.js +3 -3
  6. package/scripts/install.ps1 +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/postinstall.js +47 -58
  9. package/shared/ai.ts +118 -202
  10. package/shared/config.ts +37 -39
  11. package/shared/logger.ts +6 -14
  12. package/shared/paths.ts +10 -16
  13. package/shared/relay.ts +89 -98
  14. package/supervisor/backend.ts +77 -89
  15. package/supervisor/chat/ARCHITECTURE.md +13 -14
  16. package/supervisor/chat/OnboardWizard.tsx +2194 -3419
  17. package/supervisor/chat/fluxy-main.tsx +495 -633
  18. package/supervisor/chat/fluxy.html +24 -36
  19. package/supervisor/chat/onboard-main.tsx +8 -8
  20. package/supervisor/chat/onboard.html +10 -13
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
  22. package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
  23. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
  24. package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
  25. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
  26. package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
  27. package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
  28. package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
  29. package/supervisor/chat/src/hooks/useChat.ts +210 -259
  30. package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
  31. package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
  32. package/supervisor/chat/src/lib/auth.ts +20 -23
  33. package/supervisor/chat/src/lib/ws-client.ts +113 -125
  34. package/supervisor/chat/src/styles/globals.css +76 -85
  35. package/supervisor/file-saver.ts +39 -52
  36. package/supervisor/fluxy-agent.ts +213 -292
  37. package/supervisor/index.ts +895 -1263
  38. package/supervisor/scheduler.ts +237 -299
  39. package/supervisor/tunnel.ts +113 -167
  40. package/supervisor/vite-dev.ts +51 -61
  41. package/supervisor/widget.js +135 -147
  42. package/supervisor/worker.ts +45 -49
  43. package/tsconfig.json +18 -24
  44. package/vite.config.ts +48 -55
  45. package/vite.fluxy.config.ts +34 -34
  46. package/worker/claude-auth.ts +202 -265
  47. package/worker/codex-auth.ts +144 -181
  48. package/worker/db.ts +63 -191
  49. package/worker/index.ts +601 -842
  50. package/workspace/MYSELF.md +1 -1
  51. package/workspace/PULSE.json +6 -6
  52. package/workspace/backend/index.ts +16 -19
  53. package/workspace/client/index.html +47 -60
  54. package/workspace/client/public/manifest.json +21 -21
  55. package/workspace/client/public/sw.js +29 -31
  56. package/workspace/client/src/App.tsx +91 -136
  57. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
  58. package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
  59. package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
  60. package/workspace/client/src/components/Layout/Footer.tsx +12 -12
  61. package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
  62. package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
  63. package/workspace/client/src/components/ui/avatar.tsx +85 -85
  64. package/workspace/client/src/components/ui/badge.tsx +40 -41
  65. package/workspace/client/src/components/ui/button.tsx +56 -57
  66. package/workspace/client/src/components/ui/card.tsx +75 -75
  67. package/workspace/client/src/components/ui/dialog.tsx +114 -117
  68. package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
  69. package/workspace/client/src/components/ui/input.tsx +17 -17
  70. package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
  71. package/workspace/client/src/components/ui/select.tsx +145 -151
  72. package/workspace/client/src/components/ui/separator.tsx +21 -21
  73. package/workspace/client/src/components/ui/sheet.tsx +102 -102
  74. package/workspace/client/src/components/ui/skeleton.tsx +10 -10
  75. package/workspace/client/src/components/ui/switch.tsx +26 -26
  76. package/workspace/client/src/components/ui/tabs.tsx +69 -69
  77. package/workspace/client/src/components/ui/textarea.tsx +14 -14
  78. package/workspace/client/src/components/ui/tooltip.tsx +37 -37
  79. package/workspace/client/src/lib/utils.ts +1 -1
  80. package/workspace/client/src/main.tsx +3 -3
  81. package/workspace/client/src/styles/globals.css +76 -85
  82. package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
  83. package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
  84. package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
  85. package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
  86. package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
  87. package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
  88. package/bin/cli.backup.js +0 -2138
  89. package/cli/commands/daemon.ts +0 -42
  90. package/cli/commands/init.ts +0 -32
  91. package/cli/commands/start.ts +0 -113
  92. package/cli/commands/tunnel.temp.ts +0 -206
  93. package/cli/commands/tunnel.ts +0 -227
  94. package/cli/commands/update.ts +0 -163
  95. package/cli/core/base-adapter.ts +0 -156
  96. package/cli/core/cloudflared.ts +0 -113
  97. package/cli/core/config.ts +0 -73
  98. package/cli/core/os-detector.ts +0 -43
  99. package/cli/core/server.ts +0 -109
  100. package/cli/core/types.ts +0 -15
  101. package/cli/index.ts +0 -72
  102. package/cli/platforms/darwin.ts +0 -146
  103. package/cli/platforms/index.ts +0 -21
  104. package/cli/platforms/linux.ts +0 -156
  105. package/cli/platforms/win32.ts +0 -34
  106. package/cli/utils/ui.ts +0 -37
@@ -1,759 +1,529 @@
1
- import {
2
- useState,
3
- useRef,
4
- useCallback,
5
- useEffect,
6
- type PointerEvent as RPointerEvent,
7
- type ChangeEvent
8
- } from 'react';
9
- import {
10
- SendHorizontal,
11
- Mic,
12
- Square,
13
- Trash2,
14
- Paperclip,
15
- Camera,
16
- X
17
- } from 'lucide-react';
1
+ import { useState, useRef, useCallback, useEffect, type PointerEvent as RPointerEvent, type ChangeEvent } from 'react';
2
+ import { SendHorizontal, Mic, Square, Trash2, Paperclip, Camera, X } from 'lucide-react';
18
3
  import { motion, AnimatePresence } from 'framer-motion';
19
4
  import type { Attachment } from '../../hooks/useChat';
20
5
  import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
21
6
 
22
7
  interface Props {
23
- onSend: (
24
- msg: string,
25
- attachments?: Attachment[],
26
- audioData?: string
27
- ) => void;
28
- onStop: () => void;
29
- streaming: boolean;
30
- whisperEnabled?: boolean;
31
- onTranscribe?: (audio: string) => Promise<{ transcript?: string }>;
8
+ onSend: (msg: string, attachments?: Attachment[], audioData?: string) => void;
9
+ onStop: () => void;
10
+ streaming: boolean;
11
+ whisperEnabled?: boolean;
12
+ onTranscribe?: (audio: string) => Promise<{ transcript?: string }>;
32
13
  }
33
14
 
34
15
  function formatTime(s: number) {
35
- const mins = Math.floor(s / 60);
36
- const secs = s % 60;
37
- return `${mins}:${secs.toString().padStart(2, '0')}`;
16
+ const mins = Math.floor(s / 60);
17
+ const secs = s % 60;
18
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
38
19
  }
39
20
 
40
21
  /** Compress an image to fit under maxBytes while staying visually clear for an LLM. */
41
- function compressImage(
42
- dataUrl: string,
43
- maxBytes = 4 * 1024 * 1024
44
- ): Promise<string> {
45
- return new Promise(resolve => {
46
- const img = new Image();
47
- img.onload = () => {
48
- const MAX_DIM = 1600;
49
- let { width, height } = img;
50
-
51
- // Scale down if larger than MAX_DIM on either axis
52
- if (width > MAX_DIM || height > MAX_DIM) {
53
- const ratio = Math.min(MAX_DIM / width, MAX_DIM / height);
54
- width = Math.round(width * ratio);
55
- height = Math.round(height * ratio);
56
- }
57
-
58
- const canvas = document.createElement('canvas');
59
- canvas.width = width;
60
- canvas.height = height;
61
- const ctx = canvas.getContext('2d')!;
62
- ctx.drawImage(img, 0, 0, width, height);
63
-
64
- // Try quality levels until under maxBytes
65
- for (const q of [0.8, 0.6, 0.4, 0.3]) {
66
- const result = canvas.toDataURL('image/jpeg', q);
67
- const size = Math.round(
68
- (result.length - 'data:image/jpeg;base64,'.length) * 0.75
69
- );
70
- if (size <= maxBytes) {
71
- resolve(result);
72
- return;
73
- }
74
- }
75
- // Fallback: lowest quality
76
- resolve(canvas.toDataURL('image/jpeg', 0.2));
77
- };
78
- img.onerror = () => resolve(dataUrl); // pass through on error
79
- img.src = dataUrl;
80
- });
22
+ function compressImage(dataUrl: string, maxBytes = 4 * 1024 * 1024): Promise<string> {
23
+ return new Promise((resolve) => {
24
+ const img = new Image();
25
+ img.onload = () => {
26
+ const MAX_DIM = 1600;
27
+ let { width, height } = img;
28
+
29
+ // Scale down if larger than MAX_DIM on either axis
30
+ if (width > MAX_DIM || height > MAX_DIM) {
31
+ const ratio = Math.min(MAX_DIM / width, MAX_DIM / height);
32
+ width = Math.round(width * ratio);
33
+ height = Math.round(height * ratio);
34
+ }
35
+
36
+ const canvas = document.createElement('canvas');
37
+ canvas.width = width;
38
+ canvas.height = height;
39
+ const ctx = canvas.getContext('2d')!;
40
+ ctx.drawImage(img, 0, 0, width, height);
41
+
42
+ // Try quality levels until under maxBytes
43
+ for (const q of [0.8, 0.6, 0.4, 0.3]) {
44
+ const result = canvas.toDataURL('image/jpeg', q);
45
+ const size = Math.round((result.length - 'data:image/jpeg;base64,'.length) * 0.75);
46
+ if (size <= maxBytes) { resolve(result); return; }
47
+ }
48
+ // Fallback: lowest quality
49
+ resolve(canvas.toDataURL('image/jpeg', 0.2));
50
+ };
51
+ img.onerror = () => resolve(dataUrl); // pass through on error
52
+ img.src = dataUrl;
53
+ });
81
54
  }
82
55
 
83
56
  const DRAFT_KEY = 'fluxy_draft';
84
57
 
85
- export default function InputBar({
86
- onSend,
87
- onStop,
88
- streaming,
89
- whisperEnabled,
90
- onTranscribe
91
- }: Props) {
92
- const {
93
- start: startSpeech,
94
- stop: stopSpeech,
95
- abort: abortSpeech,
96
- isSupported: webSpeechSupported
97
- } = useSpeechRecognition();
98
- const voiceEnabled = whisperEnabled || webSpeechSupported;
99
- console.log(
100
- '[InputBar] render - whisperEnabled:',
101
- whisperEnabled,
102
- 'webSpeechSupported:',
103
- webSpeechSupported,
104
- 'voiceEnabled:',
105
- voiceEnabled
106
- );
107
- const [text, setText] = useState(() => {
108
- try {
109
- return localStorage.getItem(DRAFT_KEY) || '';
110
- } catch {
111
- return '';
112
- }
113
- });
114
- const [attachments, setAttachments] = useState<Attachment[]>([]);
115
- const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
116
- const [isRecording, setIsRecording] = useState(false);
117
- const [recordingTime, setRecordingTime] = useState(0);
118
- const hasText = text.trim().length > 0;
119
- const hasContent = hasText || attachments.length > 0;
120
-
121
- const textareaRef = useRef<HTMLTextAreaElement>(null);
122
- const fileRef = useRef<HTMLInputElement>(null);
123
- const cameraRef = useRef<HTMLInputElement>(null);
124
- const trashRef = useRef<HTMLDivElement>(null);
125
- const micRef = useRef<HTMLDivElement>(null);
126
- const startXRef = useRef(0);
127
- const dragRef = useRef(0);
128
- const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
129
- const isHolding = useRef(false);
130
- const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
131
- const mediaRecorderRef = useRef<MediaRecorder | null>(null);
132
- const audioChunksRef = useRef<Blob[]>([]);
133
- const streamRef = useRef<MediaStream | null>(null);
134
-
135
- // Listen for preset messages from parent window
136
- useEffect(() => {
137
- const handler = (e: MessageEvent) => {
138
- if (e.data?.type === 'fluxy:fill-input' && e.data?.text) {
139
- setText(e.data.text);
140
- // Force the input to focus when receiving a prompt fill
141
- requestAnimationFrame(() => textareaRef.current?.focus());
142
- }
143
- };
144
- window.addEventListener('message', handler);
145
- return () => window.removeEventListener('message', handler);
146
- }, []);
147
-
148
- // Set initial height and update on text change
149
- useEffect(() => {
150
- const el = textareaRef.current;
151
- if (!el) return;
152
- el.style.height = '0px';
153
- // 4 lines ~ 5.5rem = 88px at text-sm (14px * 1.625 line-height * 4)
154
- el.style.height = `${Math.min(el.scrollHeight, 88)}px`;
155
- }, [text]);
156
-
157
- // Debounced draft save to localStorage
158
- useEffect(() => {
159
- if (draftTimerRef.current) clearTimeout(draftTimerRef.current);
160
- draftTimerRef.current = setTimeout(() => {
161
- try {
162
- localStorage.setItem(DRAFT_KEY, text);
163
- } catch {}
164
- }, 500);
165
- return () => {
166
- if (draftTimerRef.current) clearTimeout(draftTimerRef.current);
167
- };
168
- }, [text]);
169
-
170
- // Recording timer
171
- useEffect(() => {
172
- if (!isRecording) return;
173
- intervalRef.current = setInterval(
174
- () => setRecordingTime(t => t + 1),
175
- 1000
176
- );
177
- return () => {
178
- if (intervalRef.current) clearInterval(intervalRef.current);
179
- };
180
- }, [isRecording]);
181
-
182
- const stopRecording = useCallback(
183
- async (cancelled: boolean) => {
184
- console.log(
185
- '[InputBar] stopRecording called, cancelled:',
186
- cancelled,
187
- 'recorder:',
188
- !!mediaRecorderRef.current,
189
- 'whisper:',
190
- whisperEnabled
191
- );
192
- if (intervalRef.current) clearInterval(intervalRef.current);
193
- if (holdTimerRef.current) {
194
- clearTimeout(holdTimerRef.current);
195
- holdTimerRef.current = null;
196
- }
197
- isHolding.current = false;
198
-
199
- const recorder = mediaRecorderRef.current;
200
- const stream = streamRef.current;
201
-
202
- if (cancelled) {
203
- stream?.getTracks().forEach(t => t.stop());
204
- mediaRecorderRef.current = null;
205
- streamRef.current = null;
206
- audioChunksRef.current = [];
207
- abortSpeech();
208
- } else if (recorder && recorder.state !== 'inactive') {
209
- // Whisper path: stop MediaRecorder and use its audio
210
- recorder.onstop = async () => {
211
- console.log(
212
- '[InputBar] recorder.onstop fired, chunks:',
213
- audioChunksRef.current.length
214
- );
215
- stream?.getTracks().forEach(t => t.stop());
216
- const blob = new Blob(audioChunksRef.current, {
217
- type: 'audio/webm'
218
- });
219
- audioChunksRef.current = [];
220
- mediaRecorderRef.current = null;
221
- streamRef.current = null;
222
-
223
- console.log('[InputBar] blob size:', blob.size);
224
- if (blob.size < 1000) return;
225
-
226
- const fileReader = new FileReader();
227
- fileReader.onloadend = async () => {
228
- const dataUrl = fileReader.result as string;
229
- const base64 = dataUrl.split(',')[1];
230
- if (!base64) return;
231
-
232
- console.log(
233
- '[InputBar] Whisper path, base64 length:',
234
- base64.length
235
- );
236
- try {
237
- let data: { transcript?: string };
238
- if (onTranscribe) {
239
- data = await onTranscribe(base64);
240
- } else {
241
- const res = await fetch(
242
- '/api/whisper/transcribe',
243
- {
244
- method: 'POST',
245
- headers: {
246
- 'Content-Type': 'application/json'
247
- },
248
- body: JSON.stringify({ audio: base64 })
249
- }
250
- );
251
- data = await res.json();
252
- }
253
- if (data.transcript?.trim()) {
254
- const pendingAtts =
255
- attachments.length > 0
256
- ? attachments
257
- : undefined;
258
- onSend(
259
- data.transcript.trim(),
260
- pendingAtts,
261
- dataUrl
262
- );
263
- if (pendingAtts) setAttachments([]);
264
- }
265
- } catch (err) {
266
- console.error(
267
- '[InputBar] Whisper transcription error:',
268
- err
269
- );
270
- }
271
- };
272
- fileReader.readAsDataURL(blob);
273
- };
274
- recorder.stop();
58
+ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, onTranscribe }: Props) {
59
+ const { start: startSpeech, stop: stopSpeech, abort: abortSpeech, isSupported: webSpeechSupported } = useSpeechRecognition();
60
+ const voiceEnabled = whisperEnabled || webSpeechSupported;
61
+ console.log('[InputBar] render - whisperEnabled:', whisperEnabled, 'webSpeechSupported:', webSpeechSupported, 'voiceEnabled:', voiceEnabled);
62
+ const [text, setText] = useState(() => {
63
+ try { return localStorage.getItem(DRAFT_KEY) || ''; } catch { return ''; }
64
+ });
65
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
66
+ const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
67
+ const [isRecording, setIsRecording] = useState(false);
68
+ const [recordingTime, setRecordingTime] = useState(0);
69
+ const hasText = text.trim().length > 0;
70
+ const hasContent = hasText || attachments.length > 0;
71
+
72
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
73
+ const fileRef = useRef<HTMLInputElement>(null);
74
+ const cameraRef = useRef<HTMLInputElement>(null);
75
+ const trashRef = useRef<HTMLDivElement>(null);
76
+ const micRef = useRef<HTMLDivElement>(null);
77
+ const startXRef = useRef(0);
78
+ const dragRef = useRef(0);
79
+ const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
80
+ const isHolding = useRef(false);
81
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
82
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
83
+ const audioChunksRef = useRef<Blob[]>([]);
84
+ const streamRef = useRef<MediaStream | null>(null);
85
+
86
+ // Auto-resize textarea up to 4 lines, then scroll
87
+ useEffect(() => {
88
+ const el = textareaRef.current;
89
+ if (!el) return;
90
+ el.style.height = '0px';
91
+ // 4 lines ~ 5.5rem = 88px at text-sm (14px * 1.625 line-height * 4)
92
+ el.style.height = `${Math.min(el.scrollHeight, 88)}px`;
93
+ }, [text]);
94
+
95
+ // Debounced draft save to localStorage
96
+ useEffect(() => {
97
+ if (draftTimerRef.current) clearTimeout(draftTimerRef.current);
98
+ draftTimerRef.current = setTimeout(() => {
99
+ try { localStorage.setItem(DRAFT_KEY, text); } catch {}
100
+ }, 500);
101
+ return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current); };
102
+ }, [text]);
103
+
104
+ // Recording timer
105
+ useEffect(() => {
106
+ if (!isRecording) return;
107
+ intervalRef.current = setInterval(() => setRecordingTime((t) => t + 1), 1000);
108
+ return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
109
+ }, [isRecording]);
110
+
111
+ const stopRecording = useCallback(async (cancelled: boolean) => {
112
+ console.log('[InputBar] stopRecording called, cancelled:', cancelled, 'recorder:', !!mediaRecorderRef.current, 'whisper:', whisperEnabled);
113
+ if (intervalRef.current) clearInterval(intervalRef.current);
114
+ if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
115
+ isHolding.current = false;
116
+
117
+ const recorder = mediaRecorderRef.current;
118
+ const stream = streamRef.current;
119
+
120
+ if (cancelled) {
121
+ stream?.getTracks().forEach((t) => t.stop());
122
+ mediaRecorderRef.current = null;
123
+ streamRef.current = null;
124
+ audioChunksRef.current = [];
125
+ abortSpeech();
126
+ } else if (recorder && recorder.state !== 'inactive') {
127
+ // Whisper path: stop MediaRecorder and use its audio
128
+ recorder.onstop = async () => {
129
+ console.log('[InputBar] recorder.onstop fired, chunks:', audioChunksRef.current.length);
130
+ stream?.getTracks().forEach((t) => t.stop());
131
+ const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
132
+ audioChunksRef.current = [];
133
+ mediaRecorderRef.current = null;
134
+ streamRef.current = null;
135
+
136
+ console.log('[InputBar] blob size:', blob.size);
137
+ if (blob.size < 1000) return;
138
+
139
+ const fileReader = new FileReader();
140
+ fileReader.onloadend = async () => {
141
+ const dataUrl = fileReader.result as string;
142
+ const base64 = dataUrl.split(',')[1];
143
+ if (!base64) return;
144
+
145
+ console.log('[InputBar] Whisper path, base64 length:', base64.length);
146
+ try {
147
+ let data: { transcript?: string };
148
+ if (onTranscribe) {
149
+ data = await onTranscribe(base64);
275
150
  } else {
276
- // Web Speech API path (no MediaRecorder): get transcript directly
277
- console.log(
278
- '[InputBar] Web Speech path, stopping speech recognition...'
279
- );
280
- stream?.getTracks().forEach(t => t.stop());
281
- mediaRecorderRef.current = null;
282
- streamRef.current = null;
283
- audioChunksRef.current = [];
284
-
285
- try {
286
- const transcript = await stopSpeech();
287
- console.log(
288
- '[InputBar] Web Speech transcript:',
289
- JSON.stringify(transcript)
290
- );
291
- if (transcript.trim()) {
292
- const pendingAtts =
293
- attachments.length > 0 ? attachments : undefined;
294
- onSend(transcript.trim(), pendingAtts);
295
- if (pendingAtts) setAttachments([]);
296
- } else {
297
- console.log(
298
- '[InputBar] Web Speech transcript was empty'
299
- );
300
- }
301
- } catch (err) {
302
- console.error('[InputBar] Web Speech stop error:', err);
303
- }
151
+ const res = await fetch('/api/whisper/transcribe', {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ audio: base64 }),
155
+ });
156
+ data = await res.json();
304
157
  }
305
-
306
- if (micRef.current) micRef.current.style.transform = '';
307
- setIsRecording(false);
308
- setRecordingTime(0);
309
- dragRef.current = 0;
310
- },
311
- [
312
- onSend,
313
- onTranscribe,
314
- attachments,
315
- whisperEnabled,
316
- abortSpeech,
317
- stopSpeech
318
- ]
319
- );
320
-
321
- // ── File handling ──
322
-
323
- const addFile = useCallback((file: File) => {
324
- const isImage = file.type.startsWith('image/');
325
- const isPdf = file.type === 'application/pdf';
326
- if (!isImage && !isPdf) return;
327
-
328
- const reader = new FileReader();
329
- reader.onload = async e => {
330
- let preview = e.target?.result as string;
331
-
332
- // Compress images to stay under API limits
333
- if (isImage) {
334
- preview = await compressImage(preview);
158
+ if (data.transcript?.trim()) {
159
+ const pendingAtts = attachments.length > 0 ? attachments : undefined;
160
+ onSend(data.transcript.trim(), pendingAtts, dataUrl);
161
+ if (pendingAtts) setAttachments([]);
335
162
  }
336
-
337
- setAttachments(prev => [
338
- ...prev,
339
- {
340
- id: Math.random().toString(36).slice(2),
341
- type: isImage ? 'image' : 'file',
342
- name: file.name,
343
- preview
344
- }
345
- ]);
163
+ } catch (err) {
164
+ console.error('[InputBar] Whisper transcription error:', err);
165
+ }
346
166
  };
347
- reader.readAsDataURL(file);
348
- }, []);
349
-
350
- const handleFileChange = useCallback(
351
- (e: ChangeEvent<HTMLInputElement>) => {
352
- const files = e.target.files;
353
- if (!files) return;
354
- for (let i = 0; i < files.length; i++) addFile(files[i]);
355
- e.target.value = '';
167
+ fileReader.readAsDataURL(blob);
168
+ };
169
+ recorder.stop();
170
+ } else {
171
+ // Web Speech API path (no MediaRecorder): get transcript directly
172
+ console.log('[InputBar] Web Speech path, stopping speech recognition...');
173
+ stream?.getTracks().forEach((t) => t.stop());
174
+ mediaRecorderRef.current = null;
175
+ streamRef.current = null;
176
+ audioChunksRef.current = [];
177
+
178
+ try {
179
+ const transcript = await stopSpeech();
180
+ console.log('[InputBar] Web Speech transcript:', JSON.stringify(transcript));
181
+ if (transcript.trim()) {
182
+ const pendingAtts = attachments.length > 0 ? attachments : undefined;
183
+ onSend(transcript.trim(), pendingAtts);
184
+ if (pendingAtts) setAttachments([]);
185
+ } else {
186
+ console.log('[InputBar] Web Speech transcript was empty');
187
+ }
188
+ } catch (err) {
189
+ console.error('[InputBar] Web Speech stop error:', err);
190
+ }
191
+ }
192
+
193
+ if (micRef.current) micRef.current.style.transform = '';
194
+ setIsRecording(false);
195
+ setRecordingTime(0);
196
+ dragRef.current = 0;
197
+ }, [onSend, onTranscribe, attachments, whisperEnabled, abortSpeech, stopSpeech]);
198
+
199
+ // ── File handling ──
200
+
201
+ const addFile = useCallback((file: File) => {
202
+ const isImage = file.type.startsWith('image/');
203
+ const isPdf = file.type === 'application/pdf';
204
+ if (!isImage && !isPdf) return;
205
+
206
+ const reader = new FileReader();
207
+ reader.onload = async (e) => {
208
+ let preview = e.target?.result as string;
209
+
210
+ // Compress images to stay under API limits
211
+ if (isImage) {
212
+ preview = await compressImage(preview);
213
+ }
214
+
215
+ setAttachments((prev) => [
216
+ ...prev,
217
+ {
218
+ id: Math.random().toString(36).slice(2),
219
+ type: isImage ? 'image' : 'file',
220
+ name: file.name,
221
+ preview,
356
222
  },
357
- [addFile]
358
- );
359
-
360
- const removeAttachment = useCallback((id: string) => {
361
- setAttachments(prev => prev.filter(a => a.id !== id));
362
- }, []);
363
-
364
- // Handle paste for images
365
- useEffect(() => {
366
- const handlePaste = (e: ClipboardEvent) => {
367
- if (!e.clipboardData?.items) return;
368
- for (const item of e.clipboardData.items) {
369
- if (item.type.startsWith('image/')) {
370
- e.preventDefault();
371
- const file = item.getAsFile();
372
- if (file) addFile(file);
373
- }
374
- }
375
- };
376
- document.addEventListener('paste', handlePaste);
377
- return () => document.removeEventListener('paste', handlePaste);
378
- }, [addFile]);
379
-
380
- const handleSend = () => {
381
- if (!hasContent) return;
382
- onSend(text, attachments.length > 0 ? attachments : undefined);
383
- setText('');
384
- setAttachments([]);
385
- try {
386
- localStorage.removeItem(DRAFT_KEY);
387
- } catch {}
388
- requestAnimationFrame(() => textareaRef.current?.focus());
223
+ ]);
389
224
  };
390
-
391
- // ── Mic pointer handlers ──
392
- const handleMicDown = useCallback(
393
- (e: RPointerEvent) => {
394
- console.log(
395
- '[InputBar] handleMicDown fired, voiceEnabled:',
396
- voiceEnabled,
397
- 'whisper:',
398
- whisperEnabled
399
- );
400
- e.preventDefault();
401
- startXRef.current = e.clientX;
402
- dragRef.current = 0;
403
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
404
-
405
- holdTimerRef.current = setTimeout(async () => {
406
- console.log(
407
- '[InputBar] hold timer fired, voiceEnabled:',
408
- voiceEnabled,
409
- 'whisper:',
410
- whisperEnabled
411
- );
412
- if (!voiceEnabled) {
413
- console.log('[InputBar] voiceEnabled is false, returning');
414
- return;
415
- }
416
- try {
417
- if (whisperEnabled) {
418
- // Whisper path: need getUserMedia + MediaRecorder for audio capture
419
- console.log(
420
- '[InputBar] Whisper path: requesting getUserMedia...'
421
- );
422
- const stream =
423
- await navigator.mediaDevices.getUserMedia({
424
- audio: true
425
- });
426
- console.log(
427
- '[InputBar] getUserMedia succeeded, tracks:',
428
- stream.getTracks().length
429
- );
430
- streamRef.current = stream;
431
- const mimeType = MediaRecorder.isTypeSupported(
432
- 'audio/webm;codecs=opus'
433
- )
434
- ? 'audio/webm;codecs=opus'
435
- : 'audio/webm';
436
- const recorder = new MediaRecorder(stream, {
437
- mimeType
438
- });
439
- audioChunksRef.current = [];
440
- recorder.ondataavailable = ev => {
441
- if (ev.data.size > 0)
442
- audioChunksRef.current.push(ev.data);
443
- };
444
- mediaRecorderRef.current = recorder;
445
- recorder.start();
446
- console.log('[InputBar] MediaRecorder started');
447
- } else {
448
- // Web Speech path: only SpeechRecognition, no getUserMedia (avoids mic conflict on mobile)
449
- console.log(
450
- '[InputBar] Web Speech path: starting SpeechRecognition only...'
451
- );
452
- startSpeech();
453
- console.log('[InputBar] SpeechRecognition started');
454
- }
455
-
456
- isHolding.current = true;
457
- setIsRecording(true);
458
- setRecordingTime(0);
459
- console.log('[InputBar] recording started, isHolding=true');
460
- } catch (err) {
461
- console.error('[InputBar] recording setup failed:', err);
462
- }
463
- }, 200);
464
- },
465
- [voiceEnabled, whisperEnabled, startSpeech]
466
- );
467
-
468
- const handleMicMove = useCallback(
469
- (e: RPointerEvent) => {
470
- if (!isHolding.current) return;
471
- const dx = Math.min(0, e.clientX - startXRef.current);
472
- dragRef.current = dx;
473
- if (micRef.current)
474
- micRef.current.style.transform = `translateX(${dx}px)`;
475
-
476
- if (trashRef.current) {
477
- const trashRect = trashRef.current.getBoundingClientRect();
478
- const trashCenterX = trashRect.left + trashRect.width / 2;
479
- if (Math.abs(e.clientX - trashCenterX) < 36) {
480
- stopRecording(true);
481
- }
482
- }
483
- },
484
- [stopRecording]
485
- );
486
-
487
- const handleMicUp = useCallback(() => {
488
- console.log('[InputBar] handleMicUp, isHolding:', isHolding.current);
489
- if (holdTimerRef.current) {
490
- clearTimeout(holdTimerRef.current);
491
- holdTimerRef.current = null;
492
- }
493
- if (!isHolding.current) {
494
- console.log('[InputBar] handleMicUp - not holding, ignoring');
495
- return;
225
+ reader.readAsDataURL(file);
226
+ }, []);
227
+
228
+ const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
229
+ const files = e.target.files;
230
+ if (!files) return;
231
+ for (let i = 0; i < files.length; i++) addFile(files[i]);
232
+ e.target.value = '';
233
+ }, [addFile]);
234
+
235
+ const removeAttachment = useCallback((id: string) => {
236
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
237
+ }, []);
238
+
239
+ // Handle paste for images
240
+ useEffect(() => {
241
+ const handlePaste = (e: ClipboardEvent) => {
242
+ if (!e.clipboardData?.items) return;
243
+ for (const item of e.clipboardData.items) {
244
+ if (item.type.startsWith('image/')) {
245
+ e.preventDefault();
246
+ const file = item.getAsFile();
247
+ if (file) addFile(file);
496
248
  }
497
- stopRecording(false);
498
- }, [stopRecording]);
499
-
500
- const handleMicCancel = useCallback(() => {
501
- console.log(
502
- '[InputBar] handleMicCancel fired, isHolding:',
503
- isHolding.current,
504
- 'holdTimer:',
505
- !!holdTimerRef.current
506
- );
507
- if (holdTimerRef.current) {
508
- clearTimeout(holdTimerRef.current);
509
- holdTimerRef.current = null;
249
+ }
250
+ };
251
+ document.addEventListener('paste', handlePaste);
252
+ return () => document.removeEventListener('paste', handlePaste);
253
+ }, [addFile]);
254
+
255
+ const handleSend = () => {
256
+ if (!hasContent) return;
257
+ onSend(text, attachments.length > 0 ? attachments : undefined);
258
+ setText('');
259
+ setAttachments([]);
260
+ try { localStorage.removeItem(DRAFT_KEY); } catch {}
261
+ requestAnimationFrame(() => textareaRef.current?.focus());
262
+ };
263
+
264
+ // ── Mic pointer handlers ──
265
+ const handleMicDown = useCallback((e: RPointerEvent) => {
266
+ console.log('[InputBar] handleMicDown fired, voiceEnabled:', voiceEnabled, 'whisper:', whisperEnabled);
267
+ e.preventDefault();
268
+ startXRef.current = e.clientX;
269
+ dragRef.current = 0;
270
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
271
+
272
+ holdTimerRef.current = setTimeout(async () => {
273
+ console.log('[InputBar] hold timer fired, voiceEnabled:', voiceEnabled, 'whisper:', whisperEnabled);
274
+ if (!voiceEnabled) {
275
+ console.log('[InputBar] voiceEnabled is false, returning');
276
+ return;
277
+ }
278
+ try {
279
+ if (whisperEnabled) {
280
+ // Whisper path: need getUserMedia + MediaRecorder for audio capture
281
+ console.log('[InputBar] Whisper path: requesting getUserMedia...');
282
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
283
+ console.log('[InputBar] getUserMedia succeeded, tracks:', stream.getTracks().length);
284
+ streamRef.current = stream;
285
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
286
+ const recorder = new MediaRecorder(stream, { mimeType });
287
+ audioChunksRef.current = [];
288
+ recorder.ondataavailable = (ev) => {
289
+ if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
290
+ };
291
+ mediaRecorderRef.current = recorder;
292
+ recorder.start();
293
+ console.log('[InputBar] MediaRecorder started');
294
+ } else {
295
+ // Web Speech path: only SpeechRecognition, no getUserMedia (avoids mic conflict on mobile)
296
+ console.log('[InputBar] Web Speech path: starting SpeechRecognition only...');
297
+ startSpeech();
298
+ console.log('[InputBar] SpeechRecognition started');
510
299
  }
511
- if (isHolding.current) stopRecording(true);
512
- }, [stopRecording]);
513
300
 
514
- return (
515
- <div className="shrink-0 p-3 relative">
516
- {/* ── Normal input (always mounted to keep keyboard alive) ── */}
517
- <div
518
- className={`flex flex-col transition-opacity duration-100 ${
519
- isRecording
520
- ? 'opacity-0 pointer-events-none'
521
- : 'opacity-100'
522
- }`}
301
+ isHolding.current = true;
302
+ setIsRecording(true);
303
+ setRecordingTime(0);
304
+ console.log('[InputBar] recording started, isHolding=true');
305
+ } catch (err) {
306
+ console.error('[InputBar] recording setup failed:', err);
307
+ }
308
+ }, 200);
309
+ }, [voiceEnabled, whisperEnabled, startSpeech]);
310
+
311
+ const handleMicMove = useCallback((e: RPointerEvent) => {
312
+ if (!isHolding.current) return;
313
+ const dx = Math.min(0, e.clientX - startXRef.current);
314
+ dragRef.current = dx;
315
+ if (micRef.current) micRef.current.style.transform = `translateX(${dx}px)`;
316
+
317
+ if (trashRef.current) {
318
+ const trashRect = trashRef.current.getBoundingClientRect();
319
+ const trashCenterX = trashRect.left + trashRect.width / 2;
320
+ if (Math.abs(e.clientX - trashCenterX) < 36) {
321
+ stopRecording(true);
322
+ }
323
+ }
324
+ }, [stopRecording]);
325
+
326
+ const handleMicUp = useCallback(() => {
327
+ console.log('[InputBar] handleMicUp, isHolding:', isHolding.current);
328
+ if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
329
+ if (!isHolding.current) {
330
+ console.log('[InputBar] handleMicUp - not holding, ignoring');
331
+ return;
332
+ }
333
+ stopRecording(false);
334
+ }, [stopRecording]);
335
+
336
+ const handleMicCancel = useCallback(() => {
337
+ console.log('[InputBar] handleMicCancel fired, isHolding:', isHolding.current, 'holdTimer:', !!holdTimerRef.current);
338
+ if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
339
+ if (isHolding.current) stopRecording(true);
340
+ }, [stopRecording]);
341
+
342
+ return (
343
+ <div className="shrink-0 p-3 relative">
344
+ {/* ── Normal input (always mounted to keep keyboard alive) ── */}
345
+ <div
346
+ className={`flex flex-col transition-opacity duration-100 ${
347
+ isRecording ? 'opacity-0 pointer-events-none' : 'opacity-100'
348
+ }`}
349
+ >
350
+ {/* ── Attachment previews ── */}
351
+ <AnimatePresence>
352
+ {attachments.length > 0 && (
353
+ <motion.div
354
+ initial={{ height: 0, opacity: 0 }}
355
+ animate={{ height: 'auto', opacity: 1 }}
356
+ exit={{ height: 0, opacity: 0 }}
357
+ transition={{ duration: 0.15 }}
358
+ className="overflow-hidden"
523
359
  >
524
- {/* ── Attachment previews ── */}
525
- <AnimatePresence>
526
- {attachments.length > 0 && (
527
- <motion.div
528
- initial={{ height: 0, opacity: 0 }}
529
- animate={{ height: 'auto', opacity: 1 }}
530
- exit={{ height: 0, opacity: 0 }}
531
- transition={{ duration: 0.15 }}
532
- className="overflow-hidden"
533
- >
534
- <div className="flex gap-2 px-3 pt-2.5 pb-2 flex-wrap bg-white/[0.04] border border-white/[0.08] rounded-2xl mb-2">
535
- {attachments.map(att => (
536
- <div
537
- key={att.id}
538
- className="relative group flex-shrink-0"
539
- >
540
- {att.type === 'image' ? (
541
- <img
542
- src={att.preview}
543
- alt={att.name}
544
- className="w-14 h-14 rounded-lg object-cover border border-white/10"
545
- />
546
- ) : (
547
- <div className="w-14 h-14 rounded-lg bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-0.5 px-1">
548
- <svg
549
- className="w-5 h-5 text-muted-foreground"
550
- fill="none"
551
- viewBox="0 0 24 24"
552
- stroke="currentColor"
553
- strokeWidth={1.5}
554
- >
555
- <path
556
- strokeLinecap="round"
557
- strokeLinejoin="round"
558
- d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
559
- />
560
- </svg>
561
- <span className="text-[8px] text-muted-foreground truncate w-full text-center">
562
- {att.name.split('.').pop()}
563
- </span>
564
- </div>
565
- )}
566
- <button
567
- onClick={() =>
568
- removeAttachment(att.id)
569
- }
570
- className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-black/80 border border-white/20 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
571
- >
572
- <X className="w-2.5 h-2.5 text-white" />
573
- </button>
574
- </div>
575
- ))}
576
- </div>
577
- </motion.div>
578
- )}
579
- </AnimatePresence>
580
-
581
- <div className="flex items-end gap-2">
582
- <div className="flex-1 flex items-end bg-white rounded-2xl px-4">
583
- <textarea
584
- ref={textareaRef}
585
- value={text}
586
- onChange={e => setText(e.target.value)}
587
- onKeyDown={e => {
588
- // Desktop: Enter sends, Shift+Enter inserts newline
589
- // Mobile: Enter always inserts newline (no hardware keyboard)
590
- const isMobile =
591
- 'ontouchstart' in window ||
592
- navigator.maxTouchPoints > 0;
593
- if (
594
- e.key === 'Enter' &&
595
- !e.shiftKey &&
596
- !isMobile
597
- ) {
598
- e.preventDefault();
599
- if (hasContent) handleSend();
600
- }
601
- }}
602
- placeholder="Type a message..."
603
- rows={1}
604
- className="flex-1 resize-none bg-transparent text-gray-900 py-3 text-sm outline-none placeholder:text-gray-400 overflow-y-auto"
605
- style={{ maxHeight: 88 }}
606
- />
607
- {/* Hidden file inputs for native PWA pickers */}
608
- <input
609
- ref={fileRef}
610
- type="file"
611
- className="hidden"
612
- accept="image/*,.pdf"
613
- multiple
614
- onChange={handleFileChange}
615
- />
616
- <input
617
- ref={cameraRef}
618
- type="file"
619
- className="hidden"
620
- accept="image/*"
621
- capture="environment"
622
- onChange={handleFileChange}
623
- />
624
- <button
625
- type="button"
626
- onClick={() => fileRef.current?.click()}
627
- className="shrink-0 p-1.5 mb-2 text-gray-400 hover:text-gray-600 transition-colors"
628
- >
629
- <Paperclip className="h-4 w-4" />
630
- </button>
631
- <button
632
- type="button"
633
- onClick={() => cameraRef.current?.click()}
634
- className="shrink-0 p-1.5 mb-2 text-gray-400 hover:text-gray-600 transition-colors"
635
- >
636
- <Camera className="h-4 w-4" />
637
- </button>
638
- </div>
639
- {streaming ? (
640
- <button
641
- onClick={onStop}
642
- className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-destructive text-destructive-foreground transition-colors"
643
- >
644
- <Square className="h-4 w-4" />
645
- </button>
646
- ) : hasText ? (
647
- <button
648
- onClick={handleSend}
649
- className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-white text-gray-900 transition-colors hover:bg-gray-100"
650
- >
651
- <SendHorizontal className="h-4 w-4" />
652
- </button>
360
+ <div className="flex gap-2 px-3 pt-2.5 pb-2 flex-wrap bg-white/[0.04] border border-white/[0.08] rounded-2xl mb-2">
361
+ {attachments.map((att) => (
362
+ <div key={att.id} className="relative group flex-shrink-0">
363
+ {att.type === 'image' ? (
364
+ <img
365
+ src={att.preview}
366
+ alt={att.name}
367
+ className="w-14 h-14 rounded-lg object-cover border border-white/10"
368
+ />
653
369
  ) : (
654
- <div
655
- className={`flex items-center justify-center h-12 w-12 shrink-0 rounded-full transition-colors touch-none select-none ${
656
- voiceEnabled
657
- ? 'bg-gradient-brand text-white cursor-pointer'
658
- : 'bg-gray-200 text-gray-400 cursor-default'
659
- }`}
660
- title={
661
- !voiceEnabled
662
- ? 'Voice input requires Chrome, Edge, or Safari. Enable Whisper in Settings for any browser.'
663
- : undefined
664
- }
665
- onPointerDown={
666
- voiceEnabled ? handleMicDown : undefined
667
- }
668
- onPointerMove={
669
- voiceEnabled ? handleMicMove : undefined
670
- }
671
- onPointerUp={voiceEnabled ? handleMicUp : undefined}
672
- onPointerCancel={
673
- voiceEnabled ? handleMicCancel : undefined
674
- }
675
- >
676
- <Mic className="h-4 w-4" />
677
- </div>
370
+ <div className="w-14 h-14 rounded-lg bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-0.5 px-1">
371
+ <svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
372
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
373
+ </svg>
374
+ <span className="text-[8px] text-muted-foreground truncate w-full text-center">{att.name.split('.').pop()}</span>
375
+ </div>
678
376
  )}
377
+ <button
378
+ onClick={() => removeAttachment(att.id)}
379
+ className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-black/80 border border-white/20 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
380
+ >
381
+ <X className="w-2.5 h-2.5 text-white" />
382
+ </button>
383
+ </div>
384
+ ))}
385
+ </div>
386
+ </motion.div>
387
+ )}
388
+ </AnimatePresence>
389
+
390
+ <div className="flex items-end gap-2">
391
+ <div className="flex-1 flex items-end bg-white rounded-2xl px-4">
392
+ <textarea
393
+ ref={textareaRef}
394
+ value={text}
395
+ onChange={(e) => setText(e.target.value)}
396
+ onKeyDown={(e) => {
397
+ // Desktop: Enter sends, Shift+Enter inserts newline
398
+ // Mobile: Enter always inserts newline (no hardware keyboard)
399
+ const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
400
+ if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
401
+ e.preventDefault();
402
+ if (hasContent) handleSend();
403
+ }
404
+ }}
405
+ placeholder="Type a message..."
406
+ rows={1}
407
+ className="flex-1 resize-none bg-transparent text-gray-900 py-3 text-sm outline-none placeholder:text-gray-400 overflow-y-auto"
408
+ style={{ maxHeight: 88 }}
409
+ />
410
+ {/* Hidden file inputs for native PWA pickers */}
411
+ <input ref={fileRef} type="file" className="hidden" accept="image/*,.pdf" multiple onChange={handleFileChange} />
412
+ <input ref={cameraRef} type="file" className="hidden" accept="image/*" capture="environment" onChange={handleFileChange} />
413
+ <button
414
+ type="button"
415
+ onClick={() => fileRef.current?.click()}
416
+ className="shrink-0 p-1.5 mb-2 text-gray-400 hover:text-gray-600 transition-colors"
417
+ >
418
+ <Paperclip className="h-4 w-4" />
419
+ </button>
420
+ <button
421
+ type="button"
422
+ onClick={() => cameraRef.current?.click()}
423
+ className="shrink-0 p-1.5 mb-2 text-gray-400 hover:text-gray-600 transition-colors"
424
+ >
425
+ <Camera className="h-4 w-4" />
426
+ </button>
427
+ </div>
428
+ {streaming ? (
429
+ <button
430
+ onClick={onStop}
431
+ className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-destructive text-destructive-foreground transition-colors"
432
+ >
433
+ <Square className="h-4 w-4" />
434
+ </button>
435
+ ) : hasText ? (
436
+ <button
437
+ onClick={handleSend}
438
+ className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-white text-gray-900 transition-colors hover:bg-gray-100"
439
+ >
440
+ <SendHorizontal className="h-4 w-4" />
441
+ </button>
442
+ ) : (
443
+ <div
444
+ className={`flex items-center justify-center h-12 w-12 shrink-0 rounded-full transition-colors touch-none select-none ${
445
+ voiceEnabled
446
+ ? 'bg-gradient-brand text-white cursor-pointer'
447
+ : 'bg-gray-200 text-gray-400 cursor-default'
448
+ }`}
449
+ title={!voiceEnabled ? 'Voice input requires Chrome, Edge, or Safari. Enable Whisper in Settings for any browser.' : undefined}
450
+ onPointerDown={voiceEnabled ? handleMicDown : undefined}
451
+ onPointerMove={voiceEnabled ? handleMicMove : undefined}
452
+ onPointerUp={voiceEnabled ? handleMicUp : undefined}
453
+ onPointerCancel={voiceEnabled ? handleMicCancel : undefined}
454
+ >
455
+ <Mic className="h-4 w-4" />
456
+ </div>
457
+ )}
458
+ </div>
459
+ </div>
460
+
461
+ {/* ── Recording overlay (absolute so no layout shift) ── */}
462
+ <AnimatePresence>
463
+ {isRecording && (
464
+ <motion.div
465
+ initial={{ opacity: 0 }}
466
+ animate={{ opacity: 1 }}
467
+ exit={{ opacity: 0 }}
468
+ transition={{ duration: 0.1 }}
469
+ className="absolute inset-0 p-3 flex items-center touch-none"
470
+ >
471
+ <div className="flex items-center w-full gap-2">
472
+ {/* Left: pulsing dot + timer (no bg) */}
473
+ <div className="flex items-center gap-2 shrink-0">
474
+ <span className="relative flex h-2 w-2 shrink-0">
475
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75" />
476
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-destructive" />
477
+ </span>
478
+ <span className="text-sm font-medium tabular-nums">
479
+ {formatTime(recordingTime)}
480
+ </span>
481
+ </div>
482
+
483
+ {/* Pill: trash + centered slide-to-cancel + mic */}
484
+ <div className="flex-1 flex items-center bg-muted rounded-full h-12 pl-1 pr-0.5">
485
+ <div
486
+ ref={trashRef}
487
+ className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive"
488
+ >
489
+ <Trash2 className="h-4 w-4" />
490
+ </div>
491
+ <div className="flex-1 flex justify-center min-w-0">
492
+ <motion.span
493
+ className="text-[13px] whitespace-nowrap select-none font-medium"
494
+ style={{
495
+ backgroundImage: 'linear-gradient(90deg, #999 0%, #999 35%, #fff 50%, #999 65%, #999 100%)',
496
+ backgroundSize: '200% 100%',
497
+ WebkitBackgroundClip: 'text',
498
+ WebkitTextFillColor: 'transparent',
499
+ backgroundClip: 'text',
500
+ }}
501
+ animate={{ backgroundPosition: ['200% center', '-200% center'] }}
502
+ transition={{ duration: 2.5, repeat: Infinity, ease: 'linear' }}
503
+ >
504
+ ◄◄ slide to cancel
505
+ </motion.span>
506
+ </div>
507
+ {/* Draggable mic (DOM transform for 60fps) */}
508
+ <div
509
+ ref={micRef}
510
+ className="shrink-0 touch-none select-none will-change-transform"
511
+ style={{ transition: 'none' }}
512
+ onPointerDown={handleMicDown}
513
+ onPointerMove={handleMicMove}
514
+ onPointerUp={handleMicUp}
515
+ onPointerCancel={handleMicCancel}
516
+ >
517
+ <div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground">
518
+ <Mic className="h-5 w-5" />
519
+ </div>
679
520
  </div>
521
+ </div>
680
522
  </div>
523
+ </motion.div>
524
+ )}
525
+ </AnimatePresence>
681
526
 
682
- {/* ── Recording overlay (absolute so no layout shift) ── */}
683
- <AnimatePresence>
684
- {isRecording && (
685
- <motion.div
686
- initial={{ opacity: 0 }}
687
- animate={{ opacity: 1 }}
688
- exit={{ opacity: 0 }}
689
- transition={{ duration: 0.1 }}
690
- className="absolute inset-0 p-3 flex items-center touch-none"
691
- >
692
- <div className="flex items-center w-full gap-2">
693
- {/* Left: pulsing dot + timer (no bg) */}
694
- <div className="flex items-center gap-2 shrink-0">
695
- <span className="relative flex h-2 w-2 shrink-0">
696
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75" />
697
- <span className="relative inline-flex rounded-full h-2 w-2 bg-destructive" />
698
- </span>
699
- <span className="text-sm font-medium tabular-nums">
700
- {formatTime(recordingTime)}
701
- </span>
702
- </div>
703
-
704
- {/* Pill: trash + centered slide-to-cancel + mic */}
705
- <div className="flex-1 flex items-center bg-muted rounded-full h-12 pl-1 pr-0.5">
706
- <div
707
- ref={trashRef}
708
- className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive"
709
- >
710
- <Trash2 className="h-4 w-4" />
711
- </div>
712
- <div className="flex-1 flex justify-center min-w-0">
713
- <motion.span
714
- className="text-[13px] whitespace-nowrap select-none font-medium"
715
- style={{
716
- backgroundImage:
717
- 'linear-gradient(90deg, #999 0%, #999 35%, #fff 50%, #999 65%, #999 100%)',
718
- backgroundSize: '200% 100%',
719
- WebkitBackgroundClip: 'text',
720
- WebkitTextFillColor: 'transparent',
721
- backgroundClip: 'text'
722
- }}
723
- animate={{
724
- backgroundPosition: [
725
- '200% center',
726
- '-200% center'
727
- ]
728
- }}
729
- transition={{
730
- duration: 2.5,
731
- repeat: Infinity,
732
- ease: 'linear'
733
- }}
734
- >
735
- ◄◄ slide to cancel
736
- </motion.span>
737
- </div>
738
- {/* Draggable mic (DOM transform for 60fps) */}
739
- <div
740
- ref={micRef}
741
- className="shrink-0 touch-none select-none will-change-transform"
742
- style={{ transition: 'none' }}
743
- onPointerDown={handleMicDown}
744
- onPointerMove={handleMicMove}
745
- onPointerUp={handleMicUp}
746
- onPointerCancel={handleMicCancel}
747
- >
748
- <div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground">
749
- <Mic className="h-5 w-5" />
750
- </div>
751
- </div>
752
- </div>
753
- </div>
754
- </motion.div>
755
- )}
756
- </AnimatePresence>
757
- </div>
758
- );
527
+ </div>
528
+ );
759
529
  }