feat: unify voice PTT across briefing and chat views

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:58:20 -04:00
parent f146485df3
commit dd304bb556
2 changed files with 176 additions and 114 deletions
+75 -2
View File
@@ -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(() => {
</div>
</div>
<!-- Mic PTT button -->
<button
v-if="voiceEnabled && recorder.isSupported"
class="btn-attach btn-mic-ptt"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@mousedown.prevent="startPtt"
@mouseup.prevent="stopPtt"
@touchstart.prevent="startPtt"
@touchend.prevent="stopPtt"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
aria-label="Push to talk"
>
<svg v-if="!transcribingVoice" width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
</svg>
</button>
<textarea
ref="inputEl"
v-model="messageInput"
@@ -1626,6 +1680,25 @@ details[open] .thinking-summary::before {
.input-area textarea:disabled {
opacity: 0.5;
}
.btn-mic-ptt {
touch-action: none;
user-select: none;
transition: color 0.15s, opacity 0.15s;
}
.btn-mic-ptt.mic-recording {
color: #ef4444 !important;
opacity: 1 !important;
animation: mic-pulse 0.8s ease-in-out infinite;
}
.btn-mic-ptt.mic-transcribing {
color: var(--color-primary) !important;
opacity: 0.7 !important;
}
@keyframes mic-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.btn-send {
width: 34px;
min-width: 34px;