bloby-bot 0.18.15 → 0.19.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.18.15",
3
+ "version": "0.19.0",
4
4
  "releaseNotes": [
5
5
  "1. react router implemented",
6
6
  "2. new workspace design",
@@ -258,56 +258,74 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
258
258
  requestAnimationFrame(() => textareaRef.current?.focus());
259
259
  };
260
260
 
261
+ // ── Device detection ──
262
+ const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
263
+
264
+ // ── Start recording helper (shared by desktop click & mobile hold) ──
265
+ const beginRecording = useCallback(async () => {
266
+ if (!voiceEnabled) return;
267
+ try {
268
+ if (whisperEnabled) {
269
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
270
+ if (!isMobile && !pointerIsDown.current) {
271
+ // Desktop: pointer already released is fine (click), keep going
272
+ } else if (isMobile && !pointerIsDown.current) {
273
+ stream.getTracks().forEach((t) => t.stop());
274
+ return;
275
+ }
276
+ streamRef.current = stream;
277
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
278
+ const recorder = new MediaRecorder(stream, { mimeType });
279
+ audioChunksRef.current = [];
280
+ recorder.ondataavailable = (ev) => {
281
+ if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
282
+ };
283
+ mediaRecorderRef.current = recorder;
284
+ recorder.start();
285
+ } else {
286
+ startSpeech(!isMobile); // desktop: continuous=true, mobile: continuous=false
287
+ if (isMobile && !pointerIsDown.current) {
288
+ abortSpeech();
289
+ return;
290
+ }
291
+ }
292
+
293
+ isHolding.current = true;
294
+ setIsRecording(true);
295
+ setRecordingTime(0);
296
+ } catch (err) {
297
+ console.error('[InputBar] recording setup failed:', err);
298
+ }
299
+ }, [voiceEnabled, whisperEnabled, startSpeech, abortSpeech]);
300
+
261
301
  // ── Mic pointer handlers ──
262
302
  const handleMicDown = useCallback((e: RPointerEvent) => {
263
303
  e.preventDefault();
304
+
305
+ if (!isMobile) {
306
+ // Desktop: click-to-toggle
307
+ if (isRecording) {
308
+ stopRecording(false);
309
+ return;
310
+ }
311
+ pointerIsDown.current = true;
312
+ beginRecording();
313
+ return;
314
+ }
315
+
316
+ // Mobile: press-and-hold
264
317
  pointerIsDown.current = true;
265
318
  startXRef.current = e.clientX;
266
319
  dragRef.current = 0;
267
320
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
268
321
 
269
- holdTimerRef.current = setTimeout(async () => {
270
- if (!voiceEnabled) {
271
- return;
272
- }
273
- try {
274
- if (whisperEnabled) {
275
- // Whisper path: need getUserMedia + MediaRecorder for audio capture
276
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
277
- // If user released while permission dialog was showing, clean up and bail
278
- if (!pointerIsDown.current) {
279
- stream.getTracks().forEach((t) => t.stop());
280
- return;
281
- }
282
- streamRef.current = stream;
283
- const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
284
- const recorder = new MediaRecorder(stream, { mimeType });
285
- audioChunksRef.current = [];
286
- recorder.ondataavailable = (ev) => {
287
- if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
288
- };
289
- mediaRecorderRef.current = recorder;
290
- recorder.start();
291
- } else {
292
- // Web Speech path: only SpeechRecognition, no getUserMedia (avoids mic conflict on mobile)
293
- startSpeech();
294
- if (!pointerIsDown.current) {
295
- abortSpeech();
296
- return;
297
- }
298
- }
299
-
300
- isHolding.current = true;
301
- setIsRecording(true);
302
- setRecordingTime(0);
303
- } catch (err) {
304
- console.error('[InputBar] recording setup failed:', err);
305
- }
322
+ holdTimerRef.current = setTimeout(() => {
323
+ beginRecording();
306
324
  }, 200);
307
- }, [voiceEnabled, whisperEnabled, startSpeech, abortSpeech]);
325
+ }, [isMobile, isRecording, voiceEnabled, beginRecording, stopRecording]);
308
326
 
309
327
  const handleMicMove = useCallback((e: RPointerEvent) => {
310
- if (!isHolding.current) return;
328
+ if (!isMobile || !isHolding.current) return;
311
329
  const dx = Math.min(0, e.clientX - startXRef.current);
312
330
  dragRef.current = dx;
313
331
  if (micRef.current) micRef.current.style.transform = `translateX(${dx}px)`;
@@ -319,16 +337,17 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
319
337
  stopRecording(true);
320
338
  }
321
339
  }
322
- }, [stopRecording]);
340
+ }, [isMobile, stopRecording]);
323
341
 
324
342
  const handleMicUp = useCallback(() => {
325
343
  pointerIsDown.current = false;
326
344
  if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
327
- if (!isHolding.current) {
328
- return;
329
- }
345
+ // Desktop: don't stop on release (toggle mode)
346
+ if (!isMobile) return;
347
+ // Mobile: stop on release
348
+ if (!isHolding.current) return;
330
349
  stopRecording(false);
331
- }, [stopRecording]);
350
+ }, [isMobile, stopRecording]);
332
351
 
333
352
  const handleMicCancel = useCallback(() => {
334
353
  pointerIsDown.current = false;
@@ -477,44 +496,70 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
477
496
  </span>
478
497
  </div>
479
498
 
480
- {/* Pill: trash + centered slide-to-cancel + mic */}
499
+ {/* Pill: content depends on desktop vs mobile */}
481
500
  <div className="flex-1 flex items-center bg-muted rounded-full h-12 pl-1 pr-0.5">
482
- <div
483
- ref={trashRef}
484
- className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive"
485
- >
486
- <Trash2 className="h-4 w-4" />
487
- </div>
488
- <div className="flex-1 flex justify-center min-w-0">
489
- <motion.span
490
- className="text-[13px] whitespace-nowrap select-none font-medium"
491
- style={{
492
- backgroundImage: 'linear-gradient(90deg, #999 0%, #999 35%, #fff 50%, #999 65%, #999 100%)',
493
- backgroundSize: '200% 100%',
494
- WebkitBackgroundClip: 'text',
495
- WebkitTextFillColor: 'transparent',
496
- backgroundClip: 'text',
497
- }}
498
- animate={{ backgroundPosition: ['200% center', '-200% center'] }}
499
- transition={{ duration: 2.5, repeat: Infinity, ease: 'linear' }}
500
- >
501
- ◄◄ slide to cancel
502
- </motion.span>
503
- </div>
504
- {/* Draggable mic (DOM transform for 60fps) */}
505
- <div
506
- ref={micRef}
507
- className="shrink-0 touch-none select-none will-change-transform"
508
- style={{ transition: 'none' }}
509
- onPointerDown={handleMicDown}
510
- onPointerMove={handleMicMove}
511
- onPointerUp={handleMicUp}
512
- onPointerCancel={handleMicCancel}
513
- >
514
- <div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground">
515
- <Mic className="h-5 w-5" />
516
- </div>
517
- </div>
501
+ {isMobile ? (
502
+ <>
503
+ <div
504
+ ref={trashRef}
505
+ className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive"
506
+ >
507
+ <Trash2 className="h-4 w-4" />
508
+ </div>
509
+ <div className="flex-1 flex justify-center min-w-0">
510
+ <motion.span
511
+ className="text-[13px] whitespace-nowrap select-none font-medium"
512
+ style={{
513
+ backgroundImage: 'linear-gradient(90deg, #999 0%, #999 35%, #fff 50%, #999 65%, #999 100%)',
514
+ backgroundSize: '200% 100%',
515
+ WebkitBackgroundClip: 'text',
516
+ WebkitTextFillColor: 'transparent',
517
+ backgroundClip: 'text',
518
+ }}
519
+ animate={{ backgroundPosition: ['200% center', '-200% center'] }}
520
+ transition={{ duration: 2.5, repeat: Infinity, ease: 'linear' }}
521
+ >
522
+ ◄◄ slide to cancel
523
+ </motion.span>
524
+ </div>
525
+ {/* Draggable mic (DOM transform for 60fps) */}
526
+ <div
527
+ ref={micRef}
528
+ className="shrink-0 touch-none select-none will-change-transform"
529
+ style={{ transition: 'none' }}
530
+ onPointerDown={handleMicDown}
531
+ onPointerMove={handleMicMove}
532
+ onPointerUp={handleMicUp}
533
+ onPointerCancel={handleMicCancel}
534
+ >
535
+ <div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground">
536
+ <Mic className="h-5 w-5" />
537
+ </div>
538
+ </div>
539
+ </>
540
+ ) : (
541
+ <>
542
+ <button
543
+ onClick={() => stopRecording(true)}
544
+ className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
545
+ >
546
+ <Trash2 className="h-4 w-4" />
547
+ </button>
548
+ <div className="flex-1 flex justify-center min-w-0">
549
+ <span className="text-[13px] whitespace-nowrap select-none font-medium text-muted-foreground">
550
+ Click mic to stop
551
+ </span>
552
+ </div>
553
+ <button
554
+ onClick={() => stopRecording(false)}
555
+ className="shrink-0"
556
+ >
557
+ <div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors">
558
+ <Square className="h-4 w-4" />
559
+ </div>
560
+ </button>
561
+ </>
562
+ )}
518
563
  </div>
519
564
  </div>
520
565
  </motion.div>
@@ -38,7 +38,7 @@ export function useSpeechRecognition() {
38
38
 
39
39
  const isSupported = useMemo(() => isWebSpeechSupported, []);
40
40
 
41
- const start = useCallback(() => {
41
+ const start = useCallback((continuous = false) => {
42
42
  const Ctor = getSpeechRecognitionCtor();
43
43
  if (!Ctor) {
44
44
  return;
@@ -50,8 +50,8 @@ export function useSpeechRecognition() {
50
50
  }
51
51
 
52
52
  const recognition = new Ctor();
53
- recognition.continuous = false; // single utterance matches press-hold-release UX
54
- recognition.interimResults = true; // get progressive updates while speaking
53
+ recognition.continuous = continuous; // desktop: true (click-to-toggle), mobile: false (hold-to-talk)
54
+ recognition.interimResults = true; // get progressive updates while speaking
55
55
  recognition.lang = navigator.language || 'en-US';
56
56
 
57
57
  transcriptRef.current = '';
@@ -156,13 +156,13 @@ Notify your human only if importance is 7+ — otherwise log results silently.
156
156
 
157
157
  ## Self-Update
158
158
 
159
- **Check version:** `cat ~/.bloby/VERSION` (current) vs `npm view bloby version` (latest).
159
+ **Check version:** `cat ~/.bloby/VERSION` (current) vs `npm view bloby-bot version` (latest).
160
160
 
161
161
  **To update:** Create the trigger file `touch .update` — the supervisor runs the update after your turn ends. You will
162
162
  NOT die. Finish your turn normally.
163
163
 
164
164
  **On PULSE:** Occasionally check for updates (not every pulse — once every few hours). If a new version exists:
165
- 1. Read release notes: `npm view bloby releaseNotes --json`
165
+ 1. Read release notes: `npm view bloby-bot releaseNotes --json`
166
166
  2. `touch .update`
167
167
  3. Save to daily notes: "Updated from vX to vY" + notable changes to talk with your human later "Btw I updated myself this night"
168
168