From dd304bb5565938e49c8d52b014560549d774d0d8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 29 Mar 2026 21:58:20 -0400 Subject: [PATCH] feat: unify voice PTT across briefing and chat views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatView: add PTT mic button in input bar (hold to speak → transcribe → send) - BriefingView: restyle input bar to match ChatView (floating pill, circle send) - BriefingView: move listen/mic controls into input bar, remove from header - BriefingView: consistent icon-button style for speaker/mic matching ChatView Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/BriefingView.vue | 213 +++++++++++++--------------- frontend/src/views/ChatView.vue | 77 +++++++++- 2 files changed, 176 insertions(+), 114 deletions(-) diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index 97f83cb..9ac88b2 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -368,29 +368,6 @@ onMounted(async () => { - - - + + - + @@ -483,11 +488,7 @@ onMounted(async () => { @click="send" :disabled="!input.trim() || chatStore.streaming || sending" aria-label="Send" - > - - - - + >↑ @@ -675,46 +676,53 @@ onMounted(async () => { .briefing-input-bar { display: flex; + align-items: center; gap: 0.5rem; - align-items: flex-end; - padding: 0.75rem 1rem 1rem; - border-top: 1px solid var(--color-border); + padding: 0.5rem 0.5rem 0.5rem 0.75rem; + margin: 0 0.75rem 1rem; + background: var(--color-input-bar-bg); + border-radius: 20px; + box-shadow: 0 2px 12px var(--color-shadow); flex-shrink: 0; } -.briefing-input { +.briefing-textarea { flex: 1; - padding: 0.6rem 0.8rem; - border: 1px solid var(--color-border); - border-radius: 10px; - background: var(--color-bg-card); - color: var(--color-text); - font-size: 0.9rem; resize: none; - outline: none; + padding: 0.4rem 0.5rem; + border: none; + border-radius: 12px; font-family: inherit; - line-height: 1.4; + font-size: 0.95rem; + background: transparent; + color: var(--color-input-bar-text); + outline: none; max-height: 120px; overflow-y: auto; - transition: border-color 0.15s; + line-height: 1.4; } -.briefing-input:focus { border-color: var(--color-primary); } +.briefing-textarea::placeholder { color: var(--color-input-bar-placeholder); } +.briefing-textarea:disabled { opacity: 0.5; } .btn-send { - padding: 0.55rem 0.75rem; - background: linear-gradient(135deg, #6366f1, #4f46e5); - color: #fff; - border: none; - border-radius: 10px; - cursor: pointer; + width: 34px; + min-width: 34px; + height: 34px; + padding: 0; display: flex; align-items: center; justify-content: center; - transition: opacity 0.15s; + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: #fff; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 1.1rem; flex-shrink: 0; + transition: box-shadow 0.15s; } -.btn-send:disabled { opacity: 0.4; cursor: not-allowed; } -.btn-send:hover:not(:disabled) { opacity: 0.9; } +.btn-send:not(:disabled):hover { box-shadow: 0 0 14px rgba(129, 140, 248, 0.5); } +.btn-send:disabled { opacity: 0.35; cursor: default; } /* ─── Right column (News) ────────────────────────────────────────────────── */ @@ -837,72 +845,53 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); } background: color-mix(in srgb, var(--color-primary) 12%, transparent); } -/* ─── Voice buttons ──────────────────────────────────────────────────────── */ +/* ─── Icon buttons (input bar) ───────────────────────────────────────────── */ -.btn-voice-header { - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.35rem 0.75rem; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-bg-card); - color: var(--color-text-muted); - font-size: 0.8rem; - cursor: pointer; - white-space: nowrap; - transition: all 0.15s; - font-family: inherit; -} -.btn-voice-header:hover:not(:disabled) { - border-color: var(--color-primary); - color: var(--color-primary); -} -.btn-voice-header:disabled { opacity: 0.5; cursor: not-allowed; } -.btn-voice-active { - border-color: var(--color-primary) !important; - color: var(--color-primary) !important; - background: color-mix(in srgb, var(--color-primary) 10%, transparent) !important; -} -.btn-voice-busy { - border-color: var(--color-primary); - color: var(--color-primary); - animation: pulse-border 1.2s ease-in-out infinite; -} -@keyframes pulse-border { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } -} - -.btn-mic { - padding: 0.55rem 0.65rem; - background: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: 10px; - color: var(--color-text-muted); +.btn-icon { + background: none; + border: none; cursor: pointer; + color: var(--color-input-bar-text); + opacity: 0.5; + padding: 0.25rem; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - transition: all 0.15s; + transition: opacity 0.15s, color 0.15s; +} +.btn-icon:hover:not(:disabled) { opacity: 1; } +.btn-icon:disabled { opacity: 0.25; cursor: default; } +.btn-icon-active { + color: var(--color-primary) !important; + opacity: 1 !important; +} +.btn-icon-busy { + color: var(--color-primary) !important; + opacity: 0.8 !important; + animation: icon-pulse 1.2s ease-in-out infinite; +} +.btn-icon-danger { + color: #ef4444 !important; + opacity: 0.8 !important; +} + +.btn-mic-ptt { touch-action: none; user-select: none; } -.btn-mic:hover:not(:disabled) { - border-color: var(--color-primary); - color: var(--color-primary); -} -.btn-mic:disabled { opacity: 0.4; cursor: not-allowed; } -.btn-mic-active { - background: color-mix(in srgb, #ef4444 15%, transparent) !important; - border-color: #ef4444 !important; +.btn-mic-ptt.mic-recording { color: #ef4444 !important; - animation: pulse-border 0.8s ease-in-out infinite; + opacity: 1 !important; + animation: icon-pulse 0.8s ease-in-out infinite; } -.btn-mic-busy { - border-color: var(--color-primary); - color: var(--color-primary); +.btn-mic-ptt.mic-transcribing { + color: var(--color-primary) !important; + opacity: 0.7 !important; +} +@keyframes icon-pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } } /* ─── Responsive ─────────────────────────────────────────────────────────── */ diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 6361237..b51284d 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -3,8 +3,9 @@ import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useChatStore } from "@/stores/chat"; import { useSettingsStore } from "@/stores/settings"; -import { apiGet } from "@/api/client"; +import { apiGet, getVoiceStatus, transcribeAudio } from "@/api/client"; import { renderMarkdown } from "@/utils/markdown"; +import { useVoiceRecorder } from "@/composables/useVoiceRecorder"; import ChatMessage from "@/components/ChatMessage.vue"; import ToolCallCard from "@/components/ToolCallCard.vue"; import ToolConfirmCard from "@/components/ToolConfirmCard.vue"; @@ -22,6 +23,38 @@ const sending = ref(false); const summarizing = ref(false); const sidebarOpen = ref(false); +// Voice PTT +const voiceEnabled = ref(false); +const transcribingVoice = ref(false); +const recorder = useVoiceRecorder(); + +async function checkVoice() { + try { + const status = await getVoiceStatus(); + voiceEnabled.value = status.enabled && status.stt; + } catch { /* voice absent */ } +} + +async function startPtt() { + if (!voiceEnabled.value || recorder.recording.value) return; + await recorder.startRecording(); +} + +async function stopPtt() { + if (!recorder.recording.value) return; + transcribingVoice.value = true; + try { + const blob = await recorder.stopRecording(); + const { transcript } = await transcribeAudio(blob); + if (transcript.trim()) { + messageInput.value = transcript.trim(); + autoResize(); + await sendMessage(); + } + } catch { /* silent */ } + finally { transcribingVoice.value = false; } +} + // Research modal state const showResearchModal = ref(false); const researchTopic = ref(""); @@ -145,7 +178,7 @@ const inputPlaceholder = computed(() => { onMounted(async () => { document.addEventListener("keydown", onGlobalKeydown); - await Promise.all([store.fetchConversations(), loadProjects()]); + await Promise.all([store.fetchConversations(), loadProjects(), checkVoice()]); if (convId.value) { if (store.currentConversation?.id !== convId.value) { await store.fetchConversation(convId.value); @@ -847,6 +880,27 @@ onUnmounted(() => { + + +