fix(stage-tamagotchi): keep chat sync alive across stage routes (#1821)

This commit is contained in:
Asish Kumar
2026-05-19 19:08:55 +08:00
committed by GitHub
parent 8f1d3c6ab6
commit 457075d0aa
5 changed files with 132 additions and 16 deletions
@@ -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 })
@@ -1,20 +1,6 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import InteractiveArea from '../components/InteractiveArea.vue'
import WindowTitleBar from '../components/Window/TitleBar.vue'
import { useChatSyncStore } from '../stores/chat-sync'
const chatSyncStore = useChatSyncStore()
onMounted(() => {
chatSyncStore.initialize('follower')
})
onUnmounted(() => {
chatSyncStore.dispose()
})
</script>
<template>
@@ -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) => {
@@ -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')
})
})
@@ -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()
},
}
}