feat: Migrate voice capture to composable with floating push-to-talk

Extract voice recording logic from FloatingVoice.vue into useVoiceCapture
composable. TranscriptCard now does real recording instead of mock typing.
InputSettings allows voice mode toggle (WebSpeech/Whisper GPU), mic
selection, and debug audio playback. ChatInput gets a settings gear button.

Long-press on FloatBubble shows a floating TranscriptCard (push-to-talk)
instead of opening the full PromptBar. Release stops recording after a
500ms buffer. Click still opens PromptBar normally.

Parallel MediaRecorder captures raw audio in WebSpeech mode for DB save
and debug playback. Transient errors (no-speech) no longer kill sessions.
Touch selection prevention on FloatBubble for tablets.
This commit is contained in:
2026-02-15 23:33:29 -06:00
parent f3ac7986ec
commit 59cc8ee87e
5 changed files with 971 additions and 37 deletions

View File

@@ -11,9 +11,10 @@ const props = defineProps<{
const emit = defineEmits<{
click: [event: MouseEvent]
hold: [el: HTMLElement]
holdrelease: []
}>()
const HOLD_MS = 400
const HOLD_MS = 200
let holdTimer: number | null = null
let didHold = false
let holdTarget: HTMLElement | null = null
@@ -30,13 +31,18 @@ function onPointerDown(e: PointerEvent) {
function onPointerUp(e: PointerEvent) {
clearHold()
if (!didHold) {
if (didHold) {
emit('holdrelease')
} else {
emit('click', e as unknown as MouseEvent)
}
}
function onPointerCancel() {
clearHold()
if (didHold) {
emit('holdrelease')
}
}
function clearHold() {
@@ -124,7 +130,10 @@ function bubbleTitle() {
@pointerdown.prevent="onPointerDown"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
@touchstart.prevent
@contextmenu.prevent
@selectstart.prevent
@dragstart.prevent
@mouseenter="!isAnimating() && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleHoverShadow())"
@mouseleave="!isAnimating() && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleStyle().boxShadow || '')"
>
@@ -194,7 +203,8 @@ function bubbleTitle() {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
touch-action: none;
position: relative;
}
@@ -543,6 +553,14 @@ function bubbleTitle() {
50% { opacity: 0.7; }
}
/* Prevent touch selection on all children */
.agent-bubble * {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
pointer-events: none;
}
/* ====================== LABELS ====================== */
.bubble-label {