feat: voice mic, pixel life layer, enhanced transcript-debug UX

VoiceMicButton component, PixelLife aquatic layer, improved UserMessageBubble
with voice display, AgentBadge terminal switcher, ChatContainer voice integration,
FloatingTranscriptDebug ocean life enhancements, and terminal registry support.
Remove traefik config.
This commit is contained in:
2026-02-20 12:12:53 -06:00
parent b7f03a777b
commit 220d595568
19 changed files with 2221 additions and 358 deletions

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
const props = defineProps<{
isRecording: boolean
voiceMode: 'web' | 'whisper'
whisperStatus: 'offline' | 'loading' | 'ready'
disabled?: boolean
}>()
const emit = defineEmits<{
start: []
stop: []
}>()
// ── Interaction state machine ──
// idle → pointerdown → wait 250ms
// if pointerup < 250ms → "click" → locked (recording until next press+release)
// if 250ms passes → "hold" → ptt (recording until release + 500ms trail)
// locked → pointerdown → stopping
// stopping → pointerup → stop recording → idle
// ptt → pointerup → wait 500ms trail → stop recording → idle
type Mode = 'idle' | 'locked' | 'ptt' | 'stopping'
const mode = ref<Mode>('idle')
const HOLD_THRESHOLD = 250
const TRAIL_BUFFER = 500
let holdTimer: number | null = null
let trailTimer: number | null = null
function clearTimers() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
if (trailTimer) { clearTimeout(trailTimer); trailTimer = null }
}
function onPointerDown(e: PointerEvent) {
if (props.disabled) return
e.preventDefault()
// Capture pointer so we get pointerup even if cursor leaves button
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
if (mode.value === 'idle') {
// Start hold detection
holdTimer = window.setTimeout(() => {
holdTimer = null
// Still holding after 250ms → PTT mode
mode.value = 'ptt'
emit('start')
}, HOLD_THRESHOLD)
} else if (mode.value === 'locked') {
// Second press while locked → prepare to stop on release
mode.value = 'stopping'
}
}
function onPointerUp(e: PointerEvent) {
if (props.disabled) return
e.preventDefault()
if (mode.value === 'idle' && holdTimer) {
// Quick click (< 250ms) → lock mode
clearTimers()
mode.value = 'locked'
emit('start')
} else if (mode.value === 'ptt') {
// Release from PTT → 500ms trailing buffer then stop
clearTimers()
trailTimer = window.setTimeout(() => {
trailTimer = null
mode.value = 'idle'
emit('stop')
}, TRAIL_BUFFER)
} else if (mode.value === 'stopping') {
// Release from second press → stop immediately
clearTimers()
mode.value = 'idle'
emit('stop')
}
}
onBeforeUnmount(() => {
clearTimers()
})
</script>
<template>
<button
class="mic-btn"
:class="{ recording: isRecording, ptt: mode === 'ptt', disabled }"
:disabled="disabled"
@pointerdown="onPointerDown"
@pointerup="onPointerUp"
:title="isRecording ? (mode === 'ptt' ? 'Release to stop' : 'Click to stop') : 'Click or hold to record'"
>
<!-- Mic icon -->
<svg v-if="!isRecording" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<!-- Stop icon (square) when recording -->
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
<!-- Mode badge -->
<span class="mode-badge" :class="voiceMode">
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
</span>
</button>
</template>
<style scoped>
.mic-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: var(--bg-hover, rgba(255,255,255,0.06));
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 6px;
color: var(--text-muted, #888);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
touch-action: none;
user-select: none;
}
.mic-btn:hover:not(:disabled) {
background: var(--bg-hover, rgba(255,255,255,0.1));
color: var(--text-primary, #ccc);
border-color: var(--text-muted, rgba(255,255,255,0.15));
}
.mic-btn.recording {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: rgba(239, 68, 68, 0.5);
color: white;
animation: rec-pulse 1.5s ease-in-out infinite;
}
.mic-btn.recording.ptt {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
border-color: rgba(249, 115, 22, 0.5);
animation: rec-pulse-ptt 0.8s ease-in-out infinite;
}
.mic-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.mode-badge {
position: absolute;
bottom: -4px;
right: -4px;
font-size: 7px;
font-weight: 700;
font-family: 'Courier New', monospace;
padding: 1px 3px;
border-radius: 3px;
line-height: 1;
letter-spacing: 0.3px;
pointer-events: none;
}
.mode-badge.whisper {
background: rgba(16, 185, 129, 0.9);
color: white;
}
.mode-badge.web {
background: rgba(59, 130, 246, 0.9);
color: white;
}
@keyframes rec-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
}
@keyframes rec-pulse-ptt {
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.5); transform: scale(1); }
50% { box-shadow: 0 0 0 5px rgba(249, 115, 22, 0); transform: scale(1.08); }
}
</style>