diff --git a/apps/stage-tamagotchi/src/renderer/pages/chat.vue b/apps/stage-tamagotchi/src/renderer/pages/chat.vue index 0bdf063ae..581d7e6df 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/chat.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/chat.vue @@ -32,7 +32,7 @@ const { t } = useI18n() :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" + @click="toggleSpeechMuted('window_title_bar')" >
diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index ebedd28a2..eaf3ff49f 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -239,7 +239,7 @@ onMounted(() => { :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" + @click="toggleSpeechMuted('mobile_action_rail')" > diff --git a/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue b/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue index 3cf43b585..20dc38119 100644 --- a/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue +++ b/packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue @@ -46,7 +46,7 @@ function handleCleanupMessages() { :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" + @click="toggleSpeechMuted('chat_toolbar')" > diff --git a/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts b/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts index 9c1576e53..218934f3a 100644 --- a/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts +++ b/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts @@ -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,33 @@ 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 entry surface and active playback state', () => { + speechMuted.value = false + nowSpeaking.value = true + setSpeechMutedMock.mockClear() + trackSpeechMuteToggledMock.mockClear() const controls = useStopSpeakingButton() - expect(controls.speechMuted.value).toBe(true) + controls.toggleSpeechMuted('chat_toolbar') - controls.toggleSpeechMuted() + expect(setSpeechMutedMock).toHaveBeenCalledWith(true) + expect(trackSpeechMuteToggledMock).toHaveBeenCalledWith({ + muted: true, + source: 'chat_toolbar', + was_speaking: true, + }) - expect(toggleSpeechMutedMock).toHaveBeenCalledOnce() + speechMuted.value = true + nowSpeaking.value = false + + controls.toggleSpeechMuted('window_title_bar') + + expect(setSpeechMutedMock).toHaveBeenLastCalledWith(false) + expect(trackSpeechMuteToggledMock).toHaveBeenLastCalledWith({ + muted: false, + source: 'window_title_bar', + was_speaking: false, + }) }) }) diff --git a/packages/stage-layouts/src/composables/useStopSpeakingButton.ts b/packages/stage-layouts/src/composables/useStopSpeakingButton.ts index 72fdea230..1b5aca93d 100644 --- a/packages/stage-layouts/src/composables/useStopSpeakingButton.ts +++ b/packages/stage-layouts/src/composables/useStopSpeakingButton.ts @@ -1,3 +1,5 @@ +import type { SpeechMuteAnalyticsSource } from '@proj-airi/stage-ui/composables/use-analytics' + import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics' import { useSpeakingStore } from '@proj-airi/stage-ui/stores/audio' import { useSpeechOutputControlStore } from '@proj-airi/stage-ui/stores/speech-output-control' @@ -14,7 +16,7 @@ export function useStopSpeakingButton() { 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 +30,23 @@ export function useStopSpeakingButton() { speechOutputControlStore.requestStopSpeaking('manual-all') } + function toggleSpeechMuted(source: SpeechMuteAnalyticsSource) { + const muted = !speechMuted.value + const wasSpeaking = nowSpeaking.value + + speechOutputControlStore.setSpeechMuted(muted) + trackSpeechMuteToggled({ + muted, + source, + was_speaking: wasSpeaking, + }) + } + return { showStopSpeakingButton, speechMuted, stopSpeakingFromChat, stopAllSpeaking, - toggleSpeechMuted: speechOutputControlStore.toggleSpeechMuted, + toggleSpeechMuted, } } diff --git a/packages/stage-ui/src/composables/use-analytics.test.ts b/packages/stage-ui/src/composables/use-analytics.test.ts index 01df76ec0..6d5c16e04 100644 --- a/packages/stage-ui/src/composables/use-analytics.test.ts +++ b/packages/stage-ui/src/composables/use-analytics.test.ts @@ -83,6 +83,23 @@ describe('useAnalytics conversation product events', () => { }) }) + it('captures speech mute state changes without conversation or audio content', () => { + const analytics = useAnalytics() + + analytics.trackSpeechMuteToggled({ + muted: true, + was_speaking: true, + source: 'chat_toolbar', + }) + + expect(analyticsMocks.posthogCaptureMock).toHaveBeenCalledWith('speech_mute_toggled', { + app_surface: 'web', + muted: true, + source: 'chat_toolbar', + was_speaking: true, + }) + }) + it('captures custom-provider token usage without prompt or response content', () => { const analytics = useAnalytics() diff --git a/packages/stage-ui/src/composables/use-analytics.ts b/packages/stage-ui/src/composables/use-analytics.ts index 87327f4a8..b588e9e36 100644 --- a/packages/stage-ui/src/composables/use-analytics.ts +++ b/packages/stage-ui/src/composables/use-analytics.ts @@ -22,6 +22,11 @@ export type ConversationAnalyticsSurface = 'web' | 'mobile' | 'electron' */ export type ConversationAnalyticsSource = 'chat_controls' | 'history' | 'sessions_drawer' +/** + * Stable UI entry points for the persistent speech mute control. + */ +export type SpeechMuteAnalyticsSource = 'chat_toolbar' | 'mobile_action_rail' | 'window_title_bar' + export type ProviderMode = 'official' | 'custom' | 'unknown' export type ChatActivationFailureStage = 'provider_config' | 'model_list' | 'message_send' | 'llm_response' | 'tts' export type ProviderConfigStep = 'settings_auto_validate' | 'manual_chat_ping' | 'onboarding_validate' @@ -654,6 +659,19 @@ export function useAnalytics() { }) } + function trackSpeechMuteToggled(properties: { + muted: boolean + was_speaking: boolean + source: SpeechMuteAnalyticsSource + }) { + if (!canCapture()) + return + posthog.capture('speech_mute_toggled', { + ...properties, + app_surface: getConversationAnalyticsSurface(), + }) + } + function trackChatSessionSelected(properties: { source: 'sessions_drawer', message_count: number, cloud_synced: boolean }) { if (!canCapture()) return @@ -1305,6 +1323,7 @@ export function useAnalytics() { trackProviderConfigCompleted, trackOfficialProviderEnabled, trackTtsStopClicked, + trackSpeechMuteToggled, trackChatSessionSelected, trackChatMessageDeleted, trackChatMessagesCleared,