diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue index fdf618593..ba51f6a8c 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue @@ -21,10 +21,12 @@ import { useRouter } from 'vue-router' import JournalToolCallBlock from './chat-tool-renderers/journal-tool-call-block.vue' +import { useHearingInputChannel } from '../composables/use-hearing-input-channel' import { artistryToolReferences, widgetToolReferences } from '../stores/tools' const router = useRouter() const messageInput = ref('') +useHearingInputChannel(messageInput) const lastEnterTime = ref(0) const attachments = ref<{ type: 'image', data: string, mimeType: string, url: string }[]>([]) diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.test.ts b/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.test.ts new file mode 100644 index 000000000..5f50f405a --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.test.ts @@ -0,0 +1,82 @@ +import type { HearingInputChannelEvent } from '@proj-airi/stage-shared' + +import { hearingInputChannelName } from '@proj-airi/stage-shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref, shallowRef } from 'vue' + +import { useHearingInputChannel } from './use-hearing-input-channel' + +const broadcastChannelMock = vi.hoisted(() => ({ + useBroadcastChannel: vi.fn(), +})) + +vi.mock('@vueuse/core', () => ({ + useBroadcastChannel: broadcastChannelMock.useBroadcastChannel, +})) + +describe('useHearingInputChannel', () => { + let data: ReturnType> + + beforeEach(() => { + data = shallowRef() + broadcastChannelMock.useBroadcastChannel.mockReset() + broadcastChannelMock.useBroadcastChannel.mockReturnValue({ data }) + }) + + it('listens on the shared Hearing input channel', () => { + useHearingInputChannel(ref('')) + + expect(broadcastChannelMock.useBroadcastChannel).toHaveBeenCalledWith({ + name: hearingInputChannelName, + }) + }) + + it('replaces Provider revisions and clears only the owned suffix', async () => { + const input = ref('manual note') + useHearingInputChannel(input) + + data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'hello' } + await nextTick() + expect(input.value).toBe('manual note hello') + + data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'hello world' } + await nextTick() + expect(input.value).toBe('manual note hello world') + + data.value = { operation: 'clear', sourceId: 'utterance-1' } + await nextTick() + expect(input.value).toBe('manual note') + }) + + it('ignores stale cleanup after a new Provider utterance starts', async () => { + const input = ref('') + useHearingInputChannel(input) + + data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'first' } + await nextTick() + data.value = { operation: 'replace', sourceId: 'utterance-2', text: 'second' } + await nextTick() + expect(input.value).toBe('second') + + data.value = { operation: 'clear', sourceId: 'utterance-1' } + await nextTick() + expect(input.value).toBe('second') + + data.value = { operation: 'clear', sourceId: 'utterance-2' } + await nextTick() + expect(input.value).toBe('') + }) + + it('does not replace text after the user edits the Provider-owned suffix', async () => { + const input = ref('') + useHearingInputChannel(input) + + data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'draft' } + await nextTick() + input.value = 'user edit' + + data.value = { operation: 'replace', sourceId: 'utterance-1', text: 'provider revision' } + await nextTick() + expect(input.value).toBe('user edit') + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.ts b/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.ts new file mode 100644 index 000000000..8581d75f0 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/use-hearing-input-channel.ts @@ -0,0 +1,39 @@ +import type { HearingInputChannelEvent } from '@proj-airi/stage-shared' +import type { Ref } from 'vue' + +import { hearingInputChannelName } from '@proj-airi/stage-shared' +import { useStreamingTranscriptionInput } from '@proj-airi/stage-ui/composables/use-streaming-transcription-input' +import { useBroadcastChannel } from '@vueuse/core' +import { watch } from 'vue' + +/** Applies cross-window Hearing updates to one editable chat input. */ +export function useHearingInputChannel(input: Ref) { + const streamingInput = useStreamingTranscriptionInput(input) + const { data } = useBroadcastChannel({ + name: hearingInputChannelName, + }) + let activeSourceId: string | undefined + + watch(data, (event) => { + if (!event) + return + + if (event.operation === 'replace') { + if (!event.text.trim()) + return + + if (activeSourceId && activeSourceId !== event.sourceId) + streamingInput.clear() + + activeSourceId = event.sourceId + streamingInput.replace(event.text) + return + } + + if (event.sourceId !== activeSourceId) + return + + streamingInput.clear() + activeSourceId = undefined + }) +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index b674437da..8c98d5da9 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -1,5 +1,5 @@