From 209cc74514bb14f9fd874d04fe38c67f0c062fe9 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 25 Feb 2026 23:53:03 +0100 Subject: [PATCH] chore: bump version to 0.1645 --- CLAUDE.md | 2 +- package.json | 2 +- src/web/public/app.js | 118 ++++++++++++++++++++++++++++++++------ src/web/public/styles.css | 32 +++++++++++ 4 files changed, 134 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f29c633c..f9a3de0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1644 (must match `package.json`) +**Version**: 0.1645 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index 07301b77..e56dbd1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1644", + "version": "0.1645", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 43b8b50b..5d1b2715 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -603,6 +603,7 @@ const DeepgramProvider = { _mediaRecorder: null, _stream: null, _silenceTimeout: null, + _keepAliveInterval: null, _onResult: null, _onError: null, _onEnd: null, @@ -632,11 +633,22 @@ const DeepgramProvider = { // Notify caller so it can set up audio level meter opts.onStream?.(this._stream); - // 2. Build WebSocket URL + // 2. Detect best supported MIME type for MediaRecorder + const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']; + this._selectedMime = null; + for (const mt of mimeTypes) { + if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(mt)) { + this._selectedMime = mt; + break; + } + } + + // 3. Build WebSocket URL (no encoding param — Deepgram auto-detects from container format) + const params = new URLSearchParams({ model: 'nova-3', - smart_format: 'true', - punctuate: 'true', + smart_format: 'false', + punctuate: 'false', interim_results: 'true', utterance_end_ms: '1500', vad_events: 'true', @@ -649,14 +661,20 @@ const DeepgramProvider = { if (opts.keyterms?.length) { for (const term of opts.keyterms) { const trimmed = term.trim(); - if (trimmed) params.append('keywords', trimmed + ':2'); + if (trimmed) params.append('keyterm', trimmed + ':2'); } } - // 3. Connect WebSocket + // 4. Connect WebSocket (trim API key to avoid whitespace auth failures) + const apiKey = (opts.apiKey || '').trim(); + if (!apiKey) { + this._onError?.('No Deepgram API key configured. Add one in Settings > Voice.'); + this._cleanup(); + return; + } const wsUrl = `wss://api.deepgram.com/v1/listen?${params}`; try { - this._ws = new WebSocket(wsUrl, ['token', opts.apiKey]); + this._ws = new WebSocket(wsUrl, ['token', apiKey]); } catch (err) { this._onError?.('Failed to connect to Deepgram: ' + err.message); this._cleanup(); @@ -664,7 +682,14 @@ const DeepgramProvider = { } this._ws.onopen = () => { - // 4. Start MediaRecorder once connected + // 5. Send KeepAlive every 8s to prevent Deepgram from closing idle connections + // (covers the gap before MediaRecorder produces its first chunk) + this._keepAliveInterval = setInterval(() => { + if (this._ws?.readyState === WebSocket.OPEN) { + try { this._ws.send(JSON.stringify({ type: 'KeepAlive' })); } catch (_e) { /* ignore */ } + } + }, 8000); + // 6. Start MediaRecorder once connected this._startRecording(); }; @@ -690,8 +715,13 @@ const DeepgramProvider = { }; this._ws.onclose = (event) => { + clearInterval(this._keepAliveInterval); + this._keepAliveInterval = null; if (event.code === 1008) { this._onError?.('Authentication failed. Check your Deepgram API key in Settings > Voice.'); + } else if (event.code === 1006) { + // 1006 = abnormal closure (no close frame). Usually auth failure, expired key, or no credits. + this._onError?.('Deepgram connection failed (1006). Check your API key is valid and has credits in Settings > Voice.'); } else if (event.code !== 1000) { this._onError?.('Deepgram connection closed: ' + (event.reason || `code ${event.code}`)); } @@ -703,17 +733,7 @@ const DeepgramProvider = { _startRecording() { if (!this._stream || !this._ws || this._ws.readyState !== WebSocket.OPEN) return; - // Pick best supported MIME type - const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']; - let mimeType; - for (const mt of mimeTypes) { - if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(mt)) { - mimeType = mt; - break; - } - } - - const recorderOpts = mimeType ? { mimeType } : {}; + const recorderOpts = this._selectedMime ? { mimeType: this._selectedMime } : {}; try { this._mediaRecorder = new MediaRecorder(this._stream, recorderOpts); } catch (err) { @@ -752,6 +772,8 @@ const DeepgramProvider = { stop() { clearTimeout(this._silenceTimeout); this._silenceTimeout = null; + clearInterval(this._keepAliveInterval); + this._keepAliveInterval = null; this._stopRecording(); if (this._ws && this._ws.readyState === WebSocket.OPEN) { try { this._ws.close(1000); } catch (_e) { /* ignore */ } @@ -763,6 +785,7 @@ const DeepgramProvider = { this._ws = null; this._mediaRecorder = null; this._stream = null; + this._selectedMime = null; this._onResult = null; this._onError = null; this._onEnd = null; @@ -1092,10 +1115,68 @@ const VoiceInput = { } else { app.sendInput(trimmed).catch(() => {}); } + this._showVoiceSendBtn(); setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150); } }, + /** Show a green Enter button by transforming the gear icon in-place */ + _showVoiceSendBtn() { + // Find the gear button (mobile or desktop header) + const gear = document.querySelector('.btn-settings-mobile') || document.querySelector('.btn-settings'); + if (!gear || gear.classList.contains('voice-send-active')) return; + + // Remove existing if any + this._hideVoiceSendBtn(); + + // Save original state + this._voiceSendGear = gear; + this._voiceSendOriginalHTML = gear.innerHTML; + this._voiceSendOriginalOnclick = gear.getAttribute('onclick'); + + // Transform into green send button + gear.innerHTML = ''; + gear.classList.add('voice-send-active'); + gear.removeAttribute('onclick'); + gear.title = 'Send (Enter)'; + + // Click handler + this._voiceSendHandler = () => { + if (!app.activeSessionId) return; + // Simulate Enter key: if local echo is active, flush its buffer + send \r; + // otherwise just send \r directly to the PTY + if (app._localEchoEnabled && app._localEchoOverlay) { + const text = app._localEchoOverlay.pendingText || ''; + app._localEchoOverlay.clear(); + app._localEchoOverlay.suppressBufferDetection(); + if (text) app.sendInput(text).catch(() => {}); + setTimeout(() => app.sendInput('\r').catch(() => {}), 80); + } else { + app.sendInput('\r').catch(() => {}); + } + // Blink then restore + gear.classList.add('voice-send-blink'); + setTimeout(() => this._hideVoiceSendBtn(), 400); + }; + gear.addEventListener('click', this._voiceSendHandler); + }, + + _hideVoiceSendBtn() { + const gear = this._voiceSendGear; + if (!gear) return; + gear.removeEventListener('click', this._voiceSendHandler); + gear.classList.remove('voice-send-active', 'voice-send-blink'); + gear.innerHTML = this._voiceSendOriginalHTML || ''; + if (this._voiceSendOriginalOnclick) { + gear.setAttribute('onclick', this._voiceSendOriginalOnclick); + } + gear.title = 'App Settings'; + this._voiceSendGear = null; + this._voiceSendHandler = null; + this._voiceSendOriginalHTML = null; + this._voiceSendOriginalOnclick = null; + }, + /** Show an editable compose overlay so the user can review/edit before sending */ _showComposeOverlay(text) { document.querySelector('.voice-compose-overlay')?.remove(); @@ -1315,6 +1396,7 @@ const VoiceInput = { /** Cleanup on SSE reconnect or page unload */ cleanup() { if (this.isRecording) this.stop(); + this._hideVoiceSendBtn(); DeepgramProvider._cleanup(); this.recognition = null; this._activeProvider = null; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 11341000..5fa662ac 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2142,6 +2142,38 @@ body { } } +/* Voice send button — gear icon transforms into green Enter after voice input */ +.voice-send-active { + background: rgba(34, 197, 94, 0.2) !important; + border-color: rgba(34, 197, 94, 0.7) !important; + color: #22c55e !important; + animation: voice-send-glow 1.4s ease-in-out infinite; +} + +.voice-send-active:active { + transform: scale(0.9); + background: rgba(34, 197, 94, 0.4) !important; +} + +@keyframes voice-send-glow { + 0%, 100% { box-shadow: 0 0 4px rgba(34, 197, 94, 0.3); border-color: rgba(34, 197, 94, 0.5) !important; } + 50% { box-shadow: 0 0 12px rgba(34, 197, 94, 0.6); border-color: rgba(34, 197, 94, 0.9) !important; } +} + +.voice-send-active.voice-send-blink { + animation: voice-send-flash 0.4s ease-out !important; +} + +@keyframes voice-send-flash { + 0% { background: rgba(34, 197, 94, 0.6) !important; box-shadow: 0 0 16px rgba(34, 197, 94, 0.8); } + 100% { background: transparent !important; box-shadow: none; opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .voice-send-active { animation: none; box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); } + .voice-send-active.voice-send-blink { animation: none; } +} + .form-row input[type="password"] { width: 100%; padding: 0.5rem 0.75rem;