From 457075d0aa937649476374f9a5876db55904dc08 Mon Sep 17 00:00:00 2001
From: Asish Kumar <87874775+officialasishkumar@users.noreply.github.com>
Date: Tue, 19 May 2026 16:38:55 +0530
Subject: [PATCH] fix(stage-tamagotchi): keep chat sync alive across stage
routes (#1821)
---
apps/stage-tamagotchi/src/renderer/App.vue | 8 ++
.../src/renderer/pages/chat.vue | 14 ----
.../src/renderer/pages/index.vue | 2 -
.../stores/chat-sync-lifecycle.test.ts | 78 +++++++++++++++++++
.../renderer/stores/chat-sync-lifecycle.ts | 46 +++++++++++
5 files changed, 132 insertions(+), 16 deletions(-)
create mode 100644 apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts
create mode 100644 apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts
diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue
index cc51f7841..b5bd7a054 100644
--- a/apps/stage-tamagotchi/src/renderer/App.vue
+++ b/apps/stage-tamagotchi/src/renderer/App.vue
@@ -51,6 +51,7 @@ import {
import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
import { useLanguage } from './composables/use-language'
+import { createChatSyncWindowLifecycle } from './stores/chat-sync-lifecycle'
import { useTamagotchiMcpToolsStore } from './stores/mcp-tools'
import { useTamagotchiPluginToolsStore } from './stores/plugin-tools'
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
@@ -96,6 +97,7 @@ const getMainLocale = useElectronEventaInvoke(i18nGetLocale)
const setLocale = useElectronEventaInvoke(i18nSetLocale)
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig)
+const chatSyncLifecycle = createChatSyncWindowLifecycle(route.path)
const isChatWindowRoute = () => route.path === '/chat'
const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings')
const isWidgetsWindowRoute = () => route.path === '/widgets'
@@ -196,6 +198,8 @@ context.value.on(electronGodotStageStatusChanged, (event) => {
})
onMounted(async () => {
+ chatSyncLifecycle.initialize()
+
// NOTICE: Issue #1658
// When Electron restarts, renderer localStorage may not be flushed to disk.
// The store's onMounted hook falls back to navigator.language, which triggers
@@ -256,6 +260,10 @@ onMounted(async () => {
inferencePreload.triggerPreload()
})
+onUnmounted(() => {
+ chatSyncLifecycle.dispose()
+})
+
watch(themeColorsHue, () => {
document.documentElement.style.setProperty('--chromatic-hue', themeColorsHue.value.toString())
}, { immediate: true })
diff --git a/apps/stage-tamagotchi/src/renderer/pages/chat.vue b/apps/stage-tamagotchi/src/renderer/pages/chat.vue
index 294e0b34e..fdc032ca7 100644
--- a/apps/stage-tamagotchi/src/renderer/pages/chat.vue
+++ b/apps/stage-tamagotchi/src/renderer/pages/chat.vue
@@ -1,20 +1,6 @@
diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue
index 3c6174714..cbfdb50a2 100644
--- a/apps/stage-tamagotchi/src/renderer/pages/index.vue
+++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue
@@ -406,7 +406,6 @@ watch(enabled, async (val) => {
}, { immediate: true })
onMounted(() => {
- chatSyncStore.initialize('authority')
if (onboardingStore.needsOnboarding) {
openOnboarding()
}
@@ -418,7 +417,6 @@ onUnmounted(() => {
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
})
stopAudioInteraction()
- chatSyncStore.dispose()
})
watch(stream, async (currentStream) => {
diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts
new file mode 100644
index 000000000..287d6e5b3
--- /dev/null
+++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.test.ts
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const chatSyncStoreMock = vi.hoisted(() => ({
+ dispose: vi.fn(),
+ initialize: vi.fn(),
+}))
+
+vi.mock('./chat-sync', () => ({
+ useChatSyncStore: () => chatSyncStoreMock,
+}))
+
+describe('createChatSyncWindowLifecycle', async () => {
+ const {
+ createChatSyncWindowLifecycle,
+ resolveInitialChatSyncRoutePath,
+ } = await import('./chat-sync-lifecycle')
+
+ beforeEach(() => {
+ chatSyncStoreMock.dispose.mockClear()
+ chatSyncStoreMock.initialize.mockClear()
+ })
+
+ it('issue #1743: keeps main window chat sync owned by the renderer root', () => {
+ // https://github.com/moeru-ai/airi/issues/1743
+ const lifecycle = createChatSyncWindowLifecycle('/', '')
+
+ lifecycle.initialize()
+ lifecycle.dispose()
+
+ expect(chatSyncStoreMock.initialize).toHaveBeenCalledWith('authority')
+ expect(chatSyncStoreMock.dispose).toHaveBeenCalledTimes(1)
+ })
+
+ it('issue #1743: resolves chat windows from the initial hash before router readiness', () => {
+ // https://github.com/moeru-ai/airi/issues/1743
+ const lifecycle = createChatSyncWindowLifecycle('/', '#/chat')
+
+ lifecycle.initialize()
+ lifecycle.dispose()
+
+ expect(chatSyncStoreMock.initialize).toHaveBeenCalledWith('follower')
+ expect(chatSyncStoreMock.dispose).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not initialize chat sync for unrelated windows', () => {
+ const lifecycle = createChatSyncWindowLifecycle('/', '#/widgets')
+
+ lifecycle.initialize()
+ lifecycle.dispose()
+
+ expect(chatSyncStoreMock.initialize).not.toHaveBeenCalled()
+ expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
+ })
+
+ it('does not initialize chat sync for settings windows', () => {
+ const lifecycle = createChatSyncWindowLifecycle('/', '#/settings')
+
+ lifecycle.initialize()
+ lifecycle.dispose()
+
+ expect(chatSyncStoreMock.initialize).not.toHaveBeenCalled()
+ expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
+ })
+
+ it('does not initialize chat sync for nested settings windows', () => {
+ const lifecycle = createChatSyncWindowLifecycle('/', '#/settings/unrelated')
+
+ lifecycle.initialize()
+ lifecycle.dispose()
+
+ expect(chatSyncStoreMock.initialize).not.toHaveBeenCalled()
+ expect(chatSyncStoreMock.dispose).not.toHaveBeenCalled()
+ })
+
+ it('normalizes hash query strings when resolving the initial route', () => {
+ expect(resolveInitialChatSyncRoutePath('/', '#/chat?source=tray')).toBe('/chat')
+ })
+})
diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts
new file mode 100644
index 000000000..4c1f8307d
--- /dev/null
+++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync-lifecycle.ts
@@ -0,0 +1,46 @@
+import { useChatSyncStore } from './chat-sync'
+
+type ChatSyncWindowRole = 'authority' | 'follower'
+
+function normalizeRoutePath(routePath: string) {
+ const [path = ''] = routePath.split(/[?#]/)
+ return path || '/'
+}
+
+export function resolveInitialChatSyncRoutePath(routePath: string, hash = globalThis.location?.hash ?? '') {
+ const hashPath = hash.startsWith('#') ? hash.slice(1) : ''
+ return normalizeRoutePath(hashPath || routePath)
+}
+
+function resolveChatSyncWindowRole(routePath: string): ChatSyncWindowRole | null {
+ const path = normalizeRoutePath(routePath)
+ if (path === '/')
+ return 'authority'
+ if (path === '/chat')
+ return 'follower'
+ return null
+}
+
+/**
+ * Owns chat-sync BroadcastChannel lifecycle for one Electron renderer window.
+ *
+ * The role is captured from the initial window route and must be initialized
+ * from the renderer root. Route pages should not dispose the channel because
+ * in-window navigation can unmount them while the BrowserWindow is still alive.
+ */
+export function createChatSyncWindowLifecycle(routePath: string, hash?: string) {
+ const chatSyncStore = useChatSyncStore()
+ const role = resolveChatSyncWindowRole(resolveInitialChatSyncRoutePath(routePath, hash))
+
+ return {
+ role,
+ initialize() {
+ if (role)
+ chatSyncStore.initialize(role)
+ },
+ dispose() {
+ if (role)
+ chatSyncStore.dispose()
+ },
+ }
+}