diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts
new file mode 100644
index 000000000..6505a9a89
--- /dev/null
+++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.test.ts
@@ -0,0 +1,85 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi } from 'vitest'
+import { createApp, h, nextTick } from 'vue'
+
+import ControlsIslandStopSpeaking from './controls-island-stop-speaking.vue'
+
+const nowSpeakingRef = { value: false }
+const stopAllSpeakingMock = vi.fn()
+
+vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
+ useSpeakingStore: () => ({
+ nowSpeaking: nowSpeakingRef,
+ }),
+}))
+
+vi.mock('@proj-airi/stage-layouts/composables/useStopSpeakingButton', () => ({
+ useStopSpeakingButton: () => ({
+ stopAllSpeaking: stopAllSpeakingMock,
+ showStopSpeakingButton: nowSpeakingRef,
+ stopSpeakingFromChat: vi.fn(),
+ }),
+}))
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('pinia', () => ({
+ storeToRefs: (store: object) => store,
+}))
+
+vi.mock('reka-ui', () => ({
+ TooltipContent: { template: '
', inheritAttrs: false },
+ TooltipProvider: { template: '
' },
+ TooltipRoot: { template: '
' },
+ TooltipTrigger: { template: '
' },
+}))
+
+describe('controlsIslandStopSpeaking', () => {
+ function mountComponent() {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const app = createApp({
+ render: () => h(ControlsIslandStopSpeaking, {
+ buttonStyle: 'p-2',
+ iconClass: 'size-5',
+ }),
+ })
+ app.mount(host)
+ return { host, app }
+ }
+
+ it('renders idle state when not speaking', async () => {
+ nowSpeakingRef.value = false
+ const { host, app } = mountComponent()
+ await nextTick()
+ expect(host.querySelectorAll('button').length).toBeGreaterThan(0)
+ app.unmount()
+ host.remove()
+ })
+
+ it('renders active state when speaking', async () => {
+ nowSpeakingRef.value = true
+ const { host, app } = mountComponent()
+ await nextTick()
+ expect(host.querySelectorAll('button').length).toBeGreaterThan(0)
+ app.unmount()
+ host.remove()
+ })
+
+ it('calls stopAllSpeaking on click', async () => {
+ stopAllSpeakingMock.mockClear()
+ nowSpeakingRef.value = false
+ const { host, app } = mountComponent()
+ await nextTick()
+ const button = host.querySelector('button')
+ expect(button).toBeTruthy()
+ button!.click()
+ expect(stopAllSpeakingMock).toHaveBeenCalledTimes(1)
+ app.unmount()
+ host.remove()
+ })
+})
diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue
new file mode 100644
index 000000000..5d2c7ee02
--- /dev/null
+++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-stop-speaking.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ nowSpeaking ? t('tamagotchi.stage.controls-island.stop-speaking') : t('tamagotchi.stage.controls-island.speaker-idle') }}
+
+
+
diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue
index 093ade735..70ca86bab 100644
--- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue
+++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue
@@ -14,6 +14,7 @@ import ControlsIslandAuthButton from './controls-island-auth-button.vue'
import ControlsIslandFadeOnHover from './controls-island-fade-on-hover.vue'
import ControlsIslandHearingConfig from './controls-island-hearing-config.vue'
import ControlsIslandProfilePicker from './controls-island-profile-picker.vue'
+import ControlsIslandStopSpeaking from './controls-island-stop-speaking.vue'
import IndicatorMicVolume from './indicator-mic-volume.vue'
import {
@@ -272,6 +273,11 @@ function resetMainWindowPosition() {
+
+
diff --git a/packages/i18n/src/locales/en/tamagotchi/stage.yaml b/packages/i18n/src/locales/en/tamagotchi/stage.yaml
index 4be6cfefc..f3cf31c41 100644
--- a/packages/i18n/src/locales/en/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/en/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Move to screen center
'open-hearing-controls': Open hearing Controls
'drag-to-move-window': Drag to move window
+ 'stop-speaking': Stop speaking
+ 'speaker-idle': Speaker
'switch-to-light-mode': Switch to light mode
'switch-to-dark-mode': Switch to dark mode
'pin-on-top': Pin on top
diff --git a/packages/i18n/src/locales/es/tamagotchi/stage.yaml b/packages/i18n/src/locales/es/tamagotchi/stage.yaml
index b1778330f..c4a0fdb79 100644
--- a/packages/i18n/src/locales/es/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/es/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Move to screen center
'open-hearing-controls': Abrir controles de audición
'drag-to-move-window': Arrastra para mover la ventana
+ 'stop-speaking': Detener voz
+ 'speaker-idle': Altavoz
'switch-to-light-mode': Cambiar a modo claro
'switch-to-dark-mode': Cambiar a modo oscuro
'pin-on-top': Anclar Arriba
diff --git a/packages/i18n/src/locales/fr/tamagotchi/stage.yaml b/packages/i18n/src/locales/fr/tamagotchi/stage.yaml
index 2c06e8203..958269c93 100644
--- a/packages/i18n/src/locales/fr/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/fr/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Move to screen center
'open-hearing-controls': Ouvrir les contrôles d'écoute
'drag-to-move-window': Glisser pour déplacer la fenêtre
+ 'stop-speaking': Arrêter la parole
+ 'speaker-idle': Haut-parleur
'switch-to-light-mode': Passer au mode clair
'switch-to-dark-mode': Passer au mode sombre
'pin-on-top': Épingler en haut
diff --git a/packages/i18n/src/locales/ja/tamagotchi/stage.yaml b/packages/i18n/src/locales/ja/tamagotchi/stage.yaml
index 3500b2567..8ab6efdac 100644
--- a/packages/i18n/src/locales/ja/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/ja/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Move to screen center
'open-hearing-controls': 聴覚コントロールを開く
'drag-to-move-window': ウィンドウをドラッグして移動
+ 'stop-speaking': 発話停止
+ 'speaker-idle': スピーカー
'switch-to-light-mode': ライトモードに切り替え
'switch-to-dark-mode': ダークモードに切り替え
'pin-on-top': ピン留め
diff --git a/packages/i18n/src/locales/ko/tamagotchi/stage.yaml b/packages/i18n/src/locales/ko/tamagotchi/stage.yaml
index bf6b21f39..61bb7cdf9 100644
--- a/packages/i18n/src/locales/ko/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/ko/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Move to screen center
'open-hearing-controls': 듣기 제어 열기
'drag-to-move-window': 드레그하여 창 이동
+ 'stop-speaking': 말하기 중지
+ 'speaker-idle': 스피커
'switch-to-light-mode': 라이트 모드로 전환
'switch-to-dark-mode': 다크 모드로 전환
'pin-on-top': 맨 위에 고정
diff --git a/packages/i18n/src/locales/ru/tamagotchi/stage.yaml b/packages/i18n/src/locales/ru/tamagotchi/stage.yaml
index 89d1fcfb1..cfc186516 100644
--- a/packages/i18n/src/locales/ru/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/ru/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Переместить в центр экрана
'open-hearing-controls': Управление слухом
'drag-to-move-window': Перетащите окно
+ 'stop-speaking': Остановить воспроизведение
+ 'speaker-idle': Динамик
'switch-to-light-mode': Переключить на светлую тему
'switch-to-dark-mode': Переключить на темную тему
'pin-on-top': Прикрепить на верх
diff --git a/packages/i18n/src/locales/vi/tamagotchi/stage.yaml b/packages/i18n/src/locales/vi/tamagotchi/stage.yaml
index e7f30a394..504f2d44f 100644
--- a/packages/i18n/src/locales/vi/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/vi/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Move to screen center
'open-hearing-controls': Kiểm soát thính giác mở
'drag-to-move-window': Kéo để di chuyển cửa sổ
+ 'stop-speaking': Dừng nói
+ 'speaker-idle': Loa
'switch-to-light-mode': Chuyển sang chế độ sáng
'switch-to-dark-mode': Chuyển sang chế độ tối
'pin-on-top': Ghim lên trên
diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml
index b680e6d73..4ab0d56a6 100644
--- a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Move to screen center
'open-hearing-controls': 打开听力控制
'drag-to-move-window': 拖动以移动窗口
+ 'stop-speaking': 停止说话
+ 'speaker-idle': 扬声器
'switch-to-light-mode': 切换到亮色模式
'switch-to-dark-mode': 切换到暗色模式
'pin-on-top': 置顶窗口
diff --git a/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml
index de405e107..c0dac0eaf 100644
--- a/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml
+++ b/packages/i18n/src/locales/zh-Hant/tamagotchi/stage.yaml
@@ -18,6 +18,8 @@ docs:
'center-main-window': Move to screen center
'open-hearing-controls': 開啟聽力控制
'drag-to-move-window': 長按以移動視窗
+ 'stop-speaking': 停止說話
+ 'speaker-idle': 揚聲器
'switch-to-light-mode': 切換至亮色模式
'switch-to-dark-mode': 切換至深色模式
'pin-on-top': 置頂
diff --git a/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts b/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts
index 776a6ef1a..c53336d52 100644
--- a/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts
+++ b/packages/stage-layouts/src/composables/useStopSpeakingButton.test.ts
@@ -55,4 +55,18 @@ describe('useStopSpeakingButton', () => {
reason: 'manual-chat',
})
})
+
+ it('requests a manual-all stop without touching chat input state', () => {
+ requestStopSpeakingMock.mockClear()
+ trackTtsStopClickedMock.mockClear()
+
+ const { stopAllSpeaking } = useStopSpeakingButton()
+
+ stopAllSpeaking()
+
+ expect(requestStopSpeakingMock).toHaveBeenCalledWith('manual-all')
+ expect(trackTtsStopClickedMock).toHaveBeenCalledWith({
+ reason: 'manual-all',
+ })
+ })
})
diff --git a/packages/stage-layouts/src/composables/useStopSpeakingButton.ts b/packages/stage-layouts/src/composables/useStopSpeakingButton.ts
index 8c64955d4..305b1acd3 100644
--- a/packages/stage-layouts/src/composables/useStopSpeakingButton.ts
+++ b/packages/stage-layouts/src/composables/useStopSpeakingButton.ts
@@ -14,7 +14,7 @@ import { computed } from 'vue'
* - A Stage instance is mounted and consumes speech output stop requests.
*
* Returns:
- * - Visibility state for the button and a click handler for manual chat stops.
+ * - Visibility state for the button and click handlers for manual stops.
*/
export function useStopSpeakingButton() {
const { nowSpeaking } = storeToRefs(useSpeakingStore())
@@ -28,8 +28,14 @@ export function useStopSpeakingButton() {
speechOutputControlStore.requestStopSpeaking('manual-chat')
}
+ function stopAllSpeaking() {
+ trackTtsStopClicked({ reason: 'manual-all' })
+ speechOutputControlStore.requestStopSpeaking('manual-all')
+ }
+
return {
showStopSpeakingButton,
stopSpeakingFromChat,
+ stopAllSpeaking,
}
}
diff --git a/packages/stage-ui/src/composables/use-analytics.ts b/packages/stage-ui/src/composables/use-analytics.ts
index b0e04b928..87327f4a8 100644
--- a/packages/stage-ui/src/composables/use-analytics.ts
+++ b/packages/stage-ui/src/composables/use-analytics.ts
@@ -1,3 +1,5 @@
+import type { SpeechOutputStopReason } from '../stores/speech-output-control'
+
import posthog from 'posthog-js'
import { isStageCapacitor, isStageTamagotchi } from '@proj-airi/stage-shared'
@@ -643,7 +645,7 @@ export function useAnalytics() {
// ─── Conversation action events ─────────────────────────────────────
- function trackTtsStopClicked(properties: { reason: 'manual-chat' }) {
+ function trackTtsStopClicked(properties: { reason: SpeechOutputStopReason }) {
if (!canCapture())
return
posthog.capture('tts_stop_clicked', {
diff --git a/packages/stage-ui/src/stores/speech-output-control.test.ts b/packages/stage-ui/src/stores/speech-output-control.test.ts
index 11105f33f..e4635a08a 100644
--- a/packages/stage-ui/src/stores/speech-output-control.test.ts
+++ b/packages/stage-ui/src/stores/speech-output-control.test.ts
@@ -27,4 +27,15 @@ describe('speech output control store', () => {
reason: 'manual-chat',
})
})
+
+ it('records manual-all stop requests with monotonic sequence numbers', () => {
+ const store = useSpeechOutputControlStore()
+
+ store.requestStopSpeaking('manual-all')
+
+ expect(store.latestStopRequest).toEqual({
+ id: 1,
+ reason: 'manual-all',
+ })
+ })
})
diff --git a/packages/stage-ui/src/stores/speech-output-control.ts b/packages/stage-ui/src/stores/speech-output-control.ts
index 3e2a8004e..eee9a60df 100644
--- a/packages/stage-ui/src/stores/speech-output-control.ts
+++ b/packages/stage-ui/src/stores/speech-output-control.ts
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
-export type SpeechOutputStopReason = 'manual-chat'
+export type SpeechOutputStopReason = 'manual-chat' | 'manual-all'
/**
* Represents a user-requested stop-speaking command for the stage output host.