feat(stage): group chat controls and track speech mute (#2139)
This commit is contained in:
@@ -199,15 +199,36 @@ onMounted(() => {
|
||||
<div translate-y="[-100%]" absolute right-0 px-3 pb-3 font-sans>
|
||||
<div flex="~ col" gap-1>
|
||||
<ActionAbout />
|
||||
<button
|
||||
border="2 solid neutral-100/60 dark:neutral-800/30"
|
||||
bg="neutral-50/70 dark:neutral-800/70"
|
||||
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
|
||||
title="Conversations"
|
||||
@click="sessionsDrawerOpen = true"
|
||||
>
|
||||
<div i-solar:chat-line-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
<div flex="~ col" items-end gap-1>
|
||||
<button
|
||||
data-testid="conversation-selector-button"
|
||||
border="2 solid neutral-100/60 dark:neutral-800/30"
|
||||
bg="neutral-50/70 dark:neutral-800/70"
|
||||
w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md
|
||||
:title="t('stage.chat.sessions.title')"
|
||||
:aria-label="t('stage.chat.sessions.title')"
|
||||
@click="sessionsDrawerOpen = true"
|
||||
>
|
||||
<div i-solar:chat-line-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
<button
|
||||
data-testid="speech-mute-button"
|
||||
:class="[
|
||||
'w-fit flex items-center self-end justify-center rounded-xl border-2 border-solid p-2 backdrop-blur-md',
|
||||
'border-neutral-100/60 text-neutral-500 transition-colors active:scale-95 dark:border-neutral-800/30 dark:text-neutral-400',
|
||||
speechMuted
|
||||
? 'bg-primary-100/80 text-primary-600 dark:bg-primary-900/60 dark:text-primary-300'
|
||||
: 'bg-neutral-50/70 hover:text-primary-500 dark:bg-neutral-800/70 dark:hover:text-primary-400',
|
||||
]"
|
||||
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
|
||||
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
|
||||
:aria-pressed="speechMuted"
|
||||
@click="toggleSpeechMuted"
|
||||
>
|
||||
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone size-5" />
|
||||
<div v-else class="i-solar:volume-loud-bold-duotone size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
|
||||
<HearingConfigDialog
|
||||
v-model:enabled="enabled"
|
||||
@@ -227,23 +248,6 @@ onMounted(() => {
|
||||
</Transition>
|
||||
</button>
|
||||
</HearingConfigDialog>
|
||||
<button
|
||||
data-testid="speech-mute-button"
|
||||
:class="[
|
||||
'w-fit flex items-center self-end justify-center rounded-xl border-2 border-solid p-2 backdrop-blur-md',
|
||||
'border-neutral-100/60 text-neutral-500 transition-colors active:scale-95 dark:border-neutral-800/30 dark:text-neutral-400',
|
||||
speechMuted
|
||||
? 'bg-primary-100/80 text-primary-600 dark:bg-primary-900/60 dark:text-primary-300'
|
||||
: 'bg-neutral-50/70 hover:text-primary-500 dark:bg-neutral-800/70 dark:hover:text-primary-400',
|
||||
]"
|
||||
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
|
||||
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
|
||||
:aria-pressed="speechMuted"
|
||||
@click="toggleSpeechMuted"
|
||||
>
|
||||
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone size-5" />
|
||||
<div v-else class="i-solar:volume-loud-bold-duotone size-5" />
|
||||
</button>
|
||||
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Theme" @click="toggleDark()">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isDark" i-solar:moon-outline size-5 text="neutral-500 dark:neutral-400" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
|
||||
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
@@ -20,6 +21,7 @@ const { speechMuted, toggleSpeechMuted } = useStopSpeakingButton()
|
||||
const { t } = useI18n()
|
||||
|
||||
const backgroundDialogOpen = ref(false)
|
||||
const sessionsDrawerOpen = ref(false)
|
||||
|
||||
function handleCleanupMessages() {
|
||||
const messageCount = messages.value.filter(message => message.role !== 'system').length
|
||||
@@ -33,24 +35,40 @@ function handleCleanupMessages() {
|
||||
|
||||
<template>
|
||||
<BackgroundDialogPicker v-model="backgroundDialogOpen" />
|
||||
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
|
||||
<div absolute bottom--8 right-0 flex gap-2>
|
||||
<button
|
||||
data-testid="speech-mute-button"
|
||||
:class="[
|
||||
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
|
||||
'text-lg transition-colors transition-transform active:scale-95',
|
||||
speechMuted
|
||||
? 'bg-primary-100 text-primary-600 dark:bg-primary-900/40 dark:text-primary-300'
|
||||
: 'bg-neutral-100 text-neutral-500 hover:text-primary-500 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-primary-400',
|
||||
]"
|
||||
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
|
||||
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
|
||||
:aria-pressed="speechMuted"
|
||||
@click="toggleSpeechMuted"
|
||||
>
|
||||
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone" />
|
||||
<div v-else class="i-solar:volume-loud-bold-duotone" />
|
||||
</button>
|
||||
<div flex gap-1>
|
||||
<button
|
||||
data-testid="conversation-selector-button"
|
||||
:class="[
|
||||
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
|
||||
'bg-neutral-100 text-lg text-neutral-500 transition-colors transition-transform active:scale-95',
|
||||
'hover:text-primary-500 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-primary-400',
|
||||
]"
|
||||
:title="t('stage.chat.sessions.title')"
|
||||
:aria-label="t('stage.chat.sessions.title')"
|
||||
@click="sessionsDrawerOpen = true"
|
||||
>
|
||||
<div class="i-solar:chat-line-bold-duotone" />
|
||||
</button>
|
||||
<button
|
||||
data-testid="speech-mute-button"
|
||||
:class="[
|
||||
'max-h-[10lh] min-h-[1lh] flex items-center justify-center rounded-md p-2 outline-none',
|
||||
'text-lg transition-colors transition-transform active:scale-95',
|
||||
speechMuted
|
||||
? 'bg-primary-100 text-primary-600 dark:bg-primary-900/40 dark:text-primary-300'
|
||||
: 'bg-neutral-100 text-neutral-500 hover:text-primary-500 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-primary-400',
|
||||
]"
|
||||
:title="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
|
||||
:aria-label="speechMuted ? t('stage.speech-output.unmute') : t('stage.speech-output.mute')"
|
||||
:aria-pressed="speechMuted"
|
||||
@click="toggleSpeechMuted"
|
||||
>
|
||||
<div v-if="speechMuted" class="i-solar:volume-cross-bold-duotone" />
|
||||
<div v-else class="i-solar:volume-loud-bold-duotone" />
|
||||
</button>
|
||||
</div>
|
||||
<ViewControls />
|
||||
<button
|
||||
class="max-h-[10lh] min-h-[1lh]"
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { HearingConfig } from '@proj-airi/stage-ui/components/scenarios/dialogs/audio-input/index'
|
||||
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
|
||||
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
|
||||
@@ -26,7 +25,6 @@ import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
|
||||
|
||||
const messageInput = ref<string>('')
|
||||
const hearingPopoverOpen = ref(false)
|
||||
const sessionsDrawerOpen = ref(false)
|
||||
const isComposing = ref(false)
|
||||
const DOUBLE_ENTER_INTERVAL_MS = 300
|
||||
const TRAILING_NEWLINES_REGEX = /[\r\n]+$/
|
||||
@@ -205,24 +203,10 @@ watch(sendMode, () => {
|
||||
@compositionend="isComposing = false"
|
||||
/>
|
||||
|
||||
<!-- Bottom-left action button: Microphone -->
|
||||
<!-- Input configuration controls -->
|
||||
<div
|
||||
absolute bottom-2 left-2 z-10 flex items-center gap-2
|
||||
>
|
||||
<!-- Conversations drawer trigger -->
|
||||
<button
|
||||
:class="[
|
||||
'h-8 w-8 flex items-center justify-center rounded-md outline-none transition-all duration-200 active:scale-95',
|
||||
'text-lg text-neutral-500 dark:text-neutral-400',
|
||||
]"
|
||||
title="Conversations"
|
||||
@click="sessionsDrawerOpen = true"
|
||||
>
|
||||
<div class="i-solar:chat-line-bold-duotone h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
|
||||
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useStopSpeakingButton } from './useStopSpeakingButton'
|
||||
const nowSpeaking = ref(false)
|
||||
const speechMuted = ref(false)
|
||||
const requestStopSpeakingMock = vi.fn()
|
||||
const toggleSpeechMutedMock = vi.fn()
|
||||
const setSpeechMutedMock = vi.fn()
|
||||
const trackSpeechMuteToggledMock = vi.fn()
|
||||
const trackTtsStopClickedMock = vi.fn()
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
|
||||
@@ -18,13 +19,14 @@ vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
|
||||
vi.mock('@proj-airi/stage-ui/stores/speech-output-control', () => ({
|
||||
useSpeechOutputControlStore: () => ({
|
||||
requestStopSpeaking: requestStopSpeakingMock,
|
||||
setSpeechMuted: setSpeechMutedMock,
|
||||
speechMuted,
|
||||
toggleSpeechMuted: toggleSpeechMutedMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/composables/use-analytics', () => ({
|
||||
useAnalytics: () => ({
|
||||
trackSpeechMuteToggled: trackSpeechMuteToggledMock,
|
||||
trackTtsStopClicked: trackTtsStopClickedMock,
|
||||
}),
|
||||
}))
|
||||
@@ -74,16 +76,72 @@ describe('useStopSpeakingButton', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes persisted mute state and toggles it through the shared output store', () => {
|
||||
speechMuted.value = true
|
||||
toggleSpeechMutedMock.mockClear()
|
||||
it('tracks mute and unmute with the active playback state', async () => {
|
||||
speechMuted.value = false
|
||||
nowSpeaking.value = true
|
||||
setSpeechMutedMock.mockClear()
|
||||
trackSpeechMuteToggledMock.mockClear()
|
||||
|
||||
const controls = useStopSpeakingButton()
|
||||
|
||||
expect(controls.speechMuted.value).toBe(true)
|
||||
await controls.toggleSpeechMuted()
|
||||
|
||||
controls.toggleSpeechMuted()
|
||||
expect(setSpeechMutedMock).toHaveBeenCalledWith(true)
|
||||
expect(trackSpeechMuteToggledMock).toHaveBeenCalledWith({
|
||||
muted: true,
|
||||
was_speaking: true,
|
||||
})
|
||||
|
||||
expect(toggleSpeechMutedMock).toHaveBeenCalledOnce()
|
||||
speechMuted.value = true
|
||||
nowSpeaking.value = false
|
||||
|
||||
await controls.toggleSpeechMuted()
|
||||
|
||||
expect(setSpeechMutedMock).toHaveBeenLastCalledWith(false)
|
||||
expect(trackSpeechMuteToggledMock).toHaveBeenLastCalledWith({
|
||||
muted: false,
|
||||
was_speaking: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Electron's auxiliary /chat renderer has its own Pinia instance, so its
|
||||
// local nowSpeaking value stays false while the main Stage renderer speaks.
|
||||
//
|
||||
// The title-bar mute control now resolves state from the output host before
|
||||
// capturing speech_mute_toggled.
|
||||
it('tracks the speaking state resolved from a remote output host', async () => {
|
||||
speechMuted.value = false
|
||||
nowSpeaking.value = false
|
||||
setSpeechMutedMock.mockClear()
|
||||
trackSpeechMuteToggledMock.mockClear()
|
||||
|
||||
const resolveSpeakingState = vi.fn().mockResolvedValue(true)
|
||||
const controls = useStopSpeakingButton({ resolveSpeakingState })
|
||||
|
||||
await controls.toggleSpeechMuted()
|
||||
|
||||
expect(resolveSpeakingState).toHaveBeenCalledTimes(1)
|
||||
expect(setSpeechMutedMock).toHaveBeenCalledWith(true)
|
||||
expect(trackSpeechMuteToggledMock).toHaveBeenCalledWith({
|
||||
muted: true,
|
||||
was_speaking: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('still toggles mute without capturing a false metric when the output host is unavailable', async () => {
|
||||
speechMuted.value = false
|
||||
setSpeechMutedMock.mockClear()
|
||||
trackSpeechMuteToggledMock.mockClear()
|
||||
|
||||
const controls = useStopSpeakingButton({
|
||||
resolveSpeakingState: () => Promise.reject(new Error('output host reloading')),
|
||||
})
|
||||
|
||||
await controls.toggleSpeechMuted()
|
||||
|
||||
expect(setSpeechMutedMock).toHaveBeenCalledWith(true)
|
||||
expect(trackSpeechMuteToggledMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,11 +10,18 @@ import { computed } from 'vue'
|
||||
* Manual stops affect current playback without cancelling text generation.
|
||||
* Mute is persisted by the shared store and also blocks future TTS sessions.
|
||||
*/
|
||||
export function useStopSpeakingButton() {
|
||||
export function useStopSpeakingButton(options: {
|
||||
/**
|
||||
* Reads speaking state from the renderer that owns playback.
|
||||
*
|
||||
* @default The current renderer's speaking store.
|
||||
*/
|
||||
resolveSpeakingState?: () => boolean | Promise<boolean>
|
||||
} = {}) {
|
||||
const { nowSpeaking } = storeToRefs(useSpeakingStore())
|
||||
const speechOutputControlStore = useSpeechOutputControlStore()
|
||||
const { speechMuted } = storeToRefs(speechOutputControlStore)
|
||||
const { trackTtsStopClicked } = useAnalytics()
|
||||
const { trackSpeechMuteToggled, trackTtsStopClicked } = useAnalytics()
|
||||
|
||||
const showStopSpeakingButton = computed(() => nowSpeaking.value)
|
||||
|
||||
@@ -28,11 +35,32 @@ export function useStopSpeakingButton() {
|
||||
speechOutputControlStore.requestStopSpeaking('manual-all')
|
||||
}
|
||||
|
||||
async function toggleSpeechMuted() {
|
||||
let wasSpeaking: boolean
|
||||
try {
|
||||
wasSpeaking = await (options.resolveSpeakingState?.() ?? nowSpeaking.value)
|
||||
}
|
||||
catch {
|
||||
// Muting is the user action; analytics must not make it fail when an
|
||||
// auxiliary renderer cannot reach the output host during a reload.
|
||||
speechOutputControlStore.setSpeechMuted(!speechMuted.value)
|
||||
return
|
||||
}
|
||||
|
||||
const muted = !speechMuted.value
|
||||
|
||||
speechOutputControlStore.setSpeechMuted(muted)
|
||||
trackSpeechMuteToggled({
|
||||
muted,
|
||||
was_speaking: wasSpeaking,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
showStopSpeakingButton,
|
||||
speechMuted,
|
||||
stopSpeakingFromChat,
|
||||
stopAllSpeaking,
|
||||
toggleSpeechMuted: speechOutputControlStore.toggleSpeechMuted,
|
||||
toggleSpeechMuted,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user