From 96d0dbbb956a972fbd3124ae02581c79a2bb3d01 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 27 Jul 2026 02:01:10 +0800 Subject: [PATCH] fix(stage-tamagotchi): restore chat list actions Route persistent session mutations through the authority while keeping per-window selection local. Force the standalone chat window to use the dialog interaction boundary. Fixes #2085 --- .../src/renderer/pages/chat.vue | 10 +- .../src/renderer/stores/chat-sync.test.ts | 118 +++++++++++++-- .../src/renderer/stores/chat-sync.ts | 70 ++++++++- .../sessions-drawer.browser.test.ts | 135 ++++++++++++++++++ .../chat/components/sessions-drawer.vue | 33 ++++- 5 files changed, 350 insertions(+), 16 deletions(-) create mode 100644 packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.browser.test.ts diff --git a/apps/stage-tamagotchi/src/renderer/pages/chat.vue b/apps/stage-tamagotchi/src/renderer/pages/chat.vue index 413ce790a..e38000131 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/chat.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/chat.vue @@ -5,7 +5,10 @@ import { shallowRef } from 'vue' import InteractiveArea from '../components/InteractiveArea.vue' import WindowTitleBar from '../components/Window/TitleBar.vue' +import { useChatSyncStore } from '../stores/chat-sync' + const sessionsDrawerOpen = shallowRef(false) +const chatSync = useChatSyncStore() diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts index 6094fb803..4ac2bd0ed 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import type { ChatSessionsExport } from '@proj-airi/stage-ui/types/chat-session' +import type { ChatSessionMeta, ChatSessionsExport } from '@proj-airi/stage-ui/types/chat-session' import type { Tool } from '@xsai/shared-chat' import type { Ref } from 'vue' @@ -105,9 +105,12 @@ function assistantMessage(content: string): MockChatMessage { interface MockState { activeSessionId: Ref sessionMessages: Ref> - sessionMetas: Ref> + sessionMetas: Ref> applyRemoteSnapshot: ReturnType + createSession: ReturnType + deleteSession: ReturnType setSessionMessages: ReturnType + setActiveSession: ReturnType getSessionMessages: ReturnType importSessions: MockImportSessions ingest: ReturnType @@ -121,6 +124,8 @@ vi.mock('@proj-airi/stage-ui/stores/chat/session-store', () => ({ sessionMessages: mockState.sessionMessages, sessionMetas: mockState.sessionMetas, applyRemoteSnapshot: mockState.applyRemoteSnapshot, + createSession: mockState.createSession, + deleteSession: mockState.deleteSession, getSnapshot: vi.fn(() => ({ activeSessionId: mockState.activeSessionId.value, sessionMessages: mockState.sessionMessages.value, @@ -128,6 +133,7 @@ vi.mock('@proj-airi/stage-ui/stores/chat/session-store', () => ({ })), getSessionMessages: mockState.getSessionMessages, importSessions: mockState.importSessions, + setActiveSession: mockState.setActiveSession, setSessionMessages: mockState.setSessionMessages, }), })) @@ -208,11 +214,19 @@ describe('useChatSyncStore', async () => { const sessionMessages = ref>({ 'session-1': [{ role: 'system', content: 'init' }], }) - const sessionMetas = ref>({}) + const sessionMetas = ref>({ + 'session-1': { + sessionId: 'session-1', + userId: 'local', + characterId: 'default', + createdAt: 1, + updatedAt: 1, + }, + }) const applyRemoteSnapshot = vi.fn((snapshot: { activeSessionId: string sessionMessages: Record - sessionMetas: Record + sessionMetas: Record }) => { activeSessionId.value = snapshot.activeSessionId sessionMessages.value = snapshot.sessionMessages @@ -222,6 +236,29 @@ describe('useChatSyncStore', async () => { const setSessionMessages = vi.fn((sessionId: string, next: MockChatMessage[]) => { sessionMessages.value[sessionId] = next }) + const setActiveSession = vi.fn((sessionId: string) => { + activeSessionId.value = sessionId + }) + const createSession = vi.fn(async (characterId: string, options?: { setActive?: boolean }) => { + const sessionId = 'session-2' + sessionMetas.value[sessionId] = { + sessionId, + userId: 'local', + characterId, + createdAt: 2, + updatedAt: 2, + } + sessionMessages.value[sessionId] = [{ role: 'system', content: 'new session' }] + if (options?.setActive !== false) + activeSessionId.value = sessionId + return sessionId + }) + const deleteSession = vi.fn(async (sessionId: string) => { + delete sessionMetas.value[sessionId] + delete sessionMessages.value[sessionId] + if (activeSessionId.value === sessionId) + activeSessionId.value = 'session-1' + }) const getSessionMessages = vi.fn((sessionId: string) => sessionMessages.value[sessionId] ?? []) const importSessions = vi.fn<(payload: ChatSessionsExport) => Promise>().mockResolvedValue(undefined) @@ -244,7 +281,10 @@ describe('useChatSyncStore', async () => { sessionMessages, sessionMetas, applyRemoteSnapshot, + createSession, + deleteSession, setSessionMessages, + setActiveSession, getSessionMessages, importSessions, ingest, @@ -471,11 +511,25 @@ describe('useChatSyncStore', async () => { store.dispose() }) - it('keeps the follower chat window on its local session while applying remote snapshots', async () => { + // https://github.com/moeru-ai/airi/issues/2085 + it('issue #2085: keeps a follower-selected session when the authority has not loaded its messages', async () => { + // ROOT CAUSE: + // + // The authority snapshot can know a session through sessionMetas without + // having loaded that session's messages. The follower previously checked + // only snapshot.sessionMessages, so the next heartbeat replaced its local + // selection even though the selected session still existed. mockState.activeSessionId.value = 'session-2' mockState.sessionMessages.value = { 'session-2': [{ role: 'system', content: 'chat-window' }], } + mockState.sessionMetas.value['session-2'] = { + sessionId: 'session-2', + userId: 'local', + characterId: 'default', + createdAt: 2, + updatedAt: 2, + } const store = useChatSyncStore() store.initialize('follower') @@ -488,9 +542,23 @@ describe('useChatSyncStore', async () => { activeSessionId: 'session-1', sessionMessages: { 'session-1': [{ role: 'system', content: 'main-window' }], - 'session-2': [{ role: 'system', content: 'chat-window' }, { role: 'user', content: 'retry me' }], }, - sessionMetas: {}, + sessionMetas: { + 'session-1': { + sessionId: 'session-1', + userId: 'local', + characterId: 'default', + createdAt: 1, + updatedAt: 1, + }, + 'session-2': { + sessionId: 'session-2', + userId: 'local', + characterId: 'default', + createdAt: 2, + updatedAt: 2, + }, + }, }, }) @@ -501,13 +569,47 @@ describe('useChatSyncStore', async () => { expect(mockState.activeSessionId.value).toBe('session-2') expect(mockState.sessionMessages.value['session-2']).toEqual([ { role: 'system', content: 'chat-window' }, - { role: 'user', content: 'retry me' }, ]) authority.close() store.dispose() }) + // https://github.com/moeru-ai/airi/issues/2085 + it('issue #2085: creates a session through the authority and activates it only in the follower', async () => { + const { authorityStore, followerStore } = initializeAuthorityAndFollower() + + await expect(followerStore.requestCreateSession('default')).resolves.toBe('session-2') + + expect(mockState.createSession).toHaveBeenCalledWith('default', { setActive: false }) + expect(mockState.setActiveSession).toHaveBeenCalledWith('session-2') + + authorityStore.dispose() + followerStore.dispose() + }) + + // https://github.com/moeru-ai/airi/issues/2085 + it('issue #2085: deletes a session through the authority', async () => { + mockState.sessionMetas.value['session-2'] = { + sessionId: 'session-2', + userId: 'local', + characterId: 'default', + createdAt: 2, + updatedAt: 2, + } + mockState.sessionMessages.value['session-2'] = [{ role: 'system', content: 'remove me' }] + + const { authorityStore, followerStore } = initializeAuthorityAndFollower() + + await expect(followerStore.requestDeleteSession('session-2')).resolves.toBeUndefined() + + expect(mockState.deleteSession).toHaveBeenCalledWith('session-2') + expect(mockState.sessionMetas.value['session-2']).toBeUndefined() + + authorityStore.dispose() + followerStore.dispose() + }) + it('sends spotlight commands through shared request and response messages', async () => { mockState.ingest.mockImplementationOnce(async () => { mockState.sessionMessages.value['session-1'] = [ diff --git a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts index a3c1f2738..9cb6f24e1 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/chat-sync.ts @@ -59,6 +59,10 @@ interface SpotlightIngestResult { visibleText: string } +interface CreateSessionResult { + sessionId: string +} + interface ChatCommandMessage { type: 'command' authorityId?: string @@ -74,7 +78,7 @@ interface RetryCommandPayload { } type ChatResponsePayload - = | { ok: true, result?: SpotlightIngestResult } + = | { ok: true, result?: SpotlightIngestResult | CreateSessionResult } | { ok: false, error?: string } type ChatSyncMessage @@ -88,6 +92,8 @@ type ChatSyncMessage | ChatCommandMessage<'tool-call-rerun', ToolCallRerunPayload> | ChatCommandMessage<'cleanup', { sessionId?: string }> | ChatCommandMessage<'delete-message', { sessionId?: string, messageId?: string, index?: number }> + | ChatCommandMessage<'create-session', { characterId: string }> + | ChatCommandMessage<'delete-session', { sessionId: string }> | ChatCommandMessage<'import-sessions', ChatSessionsExport> | ({ type: 'response', requestId: string, authorityId: string } & ChatResponsePayload) @@ -277,15 +283,26 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () => function applySessionSnapshot(snapshot: SessionSnapshotPayload) { const localActiveSessionId = activeSessionId.value + const localActiveSessionMessages = sessionMessages.value[localActiveSessionId] const shouldPreserveLocalActiveSession = mode.value === 'follower' && !!localActiveSessionId - && !!snapshot.sessionMessages[localActiveSessionId] + && !!snapshot.sessionMetas[localActiveSessionId] + + const nextSessionMessages = shouldPreserveLocalActiveSession + && localActiveSessionMessages + && !snapshot.sessionMessages[localActiveSessionId] + ? { + ...snapshot.sessionMessages, + [localActiveSessionId]: localActiveSessionMessages, + } + : snapshot.sessionMessages chatSession.applyRemoteSnapshot({ ...snapshot, activeSessionId: shouldPreserveLocalActiveSession ? localActiveSessionId : snapshot.activeSessionId, + sessionMessages: nextSessionMessages, }) } @@ -468,6 +485,17 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () => case 'delete-message': executeDeleteMessage(message.payload) break + case 'create-session': + respond({ + ok: true, + result: { + sessionId: await chatSession.createSession(message.payload.characterId, { setActive: false }), + }, + }) + return + case 'delete-session': + await chatSession.deleteSession(message.payload.sessionId) + break case 'import-sessions': await chatSession.importSessions(message.payload) break @@ -707,6 +735,42 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () => }) } + /** + * Creates a persisted session on the authority and activates it in the + * requesting window. A follower must not mutate its local session store + * first because the next authority snapshot would replace that mutation. + */ + async function requestCreateSession(characterId: string) { + if (mode.value === 'authority') + return await chatSession.createSession(characterId, { setActive: true }) + + const result = await dispatch({ + type: 'command', + requestId: createRequestId(), + senderId: instanceId, + command: 'create-session', + payload: { characterId }, + }) + chatSession.setActiveSession(result.sessionId) + return result.sessionId + } + + /** Deletes a persisted session through the authority that owns session state. */ + async function requestDeleteSession(sessionId: string) { + if (mode.value === 'authority') { + await chatSession.deleteSession(sessionId) + return + } + + await dispatch({ + type: 'command', + requestId: createRequestId(), + senderId: instanceId, + command: 'delete-session', + payload: { sessionId }, + }) + } + /** Imports persisted chat sessions through the authority so every chat window receives the resulting snapshot. */ async function requestImportSessions(payload: ChatSessionsExport) { if (mode.value === 'authority') { @@ -743,6 +807,8 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () => requestToolCallRerun, requestCleanup, requestDeleteMessage, + requestCreateSession, + requestDeleteSession, requestImportSessions, } }) diff --git a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.browser.test.ts b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.browser.test.ts new file mode 100644 index 000000000..43df9b3fc --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.browser.test.ts @@ -0,0 +1,135 @@ +import { createPinia, defineStore } from 'pinia' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-vue' +import { computed, ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import ChatSessionsDrawer from './sessions-drawer.vue' + +vi.mock('../../../../composables/use-analytics', () => ({ + useAnalytics: () => ({ + trackChatSessionSelected: vi.fn(), + trackChatSessionStarted: vi.fn(), + }), +})) + +vi.mock('../../../../composables/use-breakpoints', () => ({ + useBreakpoints: () => ({ + isDesktop: computed(() => false), + }), +})) + +vi.mock('../../../../stores/auth', () => ({ + useAuthStore: defineStore('test-auth', () => ({ + userId: ref('local'), + })), +})) + +vi.mock('../../../../stores/chat/session-store', () => ({ + useChatSessionStore: defineStore('test-chat-session', () => { + const activeSessionId = ref('session-1') + const sessionMessages = ref({ + 'session-1': [{ role: 'user', content: 'Existing conversation' }], + }) + const sessionMetas = ref({ + 'session-1': { + sessionId: 'session-1', + userId: 'local', + characterId: 'default', + createdAt: 1, + updatedAt: 1, + }, + }) + + return { + activeSessionId, + sessionMessages, + sessionMetas, + createSession: vi.fn(async () => 'session-2'), + deleteSession: vi.fn(async () => undefined), + loadSession: vi.fn(async () => undefined), + setActiveSession: vi.fn((sessionId: string) => { + activeSessionId.value = sessionId + }), + } + }), +})) + +vi.mock('../../../../stores/modules/airi-card', () => ({ + useAiriCardStore: defineStore('test-airi-card', () => ({ + activeCardId: ref('default'), + })), +})) + +vi.mock('../../../../stores/modules/consciousness', () => ({ + useConsciousnessStore: defineStore('test-consciousness', () => ({ + activeModel: ref('test-model'), + })), +})) + +function createTestI18n() { + return createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + stage: { + chat: { + sessions: { + 'title': 'Conversations', + 'new': 'New conversation', + 'empty': 'No conversations', + 'delete': 'Delete conversation', + 'cloud-badge': 'Cloud synced', + 'new-chat-fallback': 'New conversation', + }, + }, + }, + }, + }, + }) +} + +async function renderDrawer(options: { + createSession?: (characterId: string) => Promise + deleteSession?: (sessionId: string) => Promise +}) { + const pinia = createPinia() + + return await render(ChatSessionsDrawer, { + props: { + modelValue: true, + presentation: 'dialog', + createSession: options.createSession, + deleteSession: options.deleteSession, + }, + global: { + plugins: [pinia, createTestI18n()], + }, + }) +} + +describe('chat sessions drawer desktop window actions', () => { + // https://github.com/moeru-ai/airi/issues/2085 + it('issue #2085: forces the dialog boundary in a narrow desktop window', async () => { + await renderDrawer({}) + + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(document.querySelector('[data-vaul-drawer]')).toBeNull() + }) + + // https://github.com/moeru-ai/airi/issues/2085 + it('issue #2085: delegates create and delete to the window action adapter', async () => { + const createSession = vi.fn(async () => 'session-2') + const deleteSession = vi.fn(async () => undefined) + const screen = await renderDrawer({ createSession, deleteSession }) + + await screen.getByRole('button', { name: 'Delete conversation' }).click() + await expect.poll(() => deleteSession.mock.calls.length).toBe(1) + expect(deleteSession).toHaveBeenCalledWith('session-1') + + await screen.getByRole('button', { name: 'New conversation' }).click() + await expect.poll(() => createSession.mock.calls.length).toBe(1) + expect(createSession).toHaveBeenCalledWith('default') + }) +}) diff --git a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue index 306a6c137..a3cdaf8be 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue @@ -16,6 +16,24 @@ import { useChatSessionStore } from '../../../../stores/chat/session-store' import { useAiriCardStore } from '../../../../stores/modules/airi-card' import { useConsciousnessStore } from '../../../../stores/modules/consciousness' +const props = withDefaults(defineProps<{ + /** + * Forces the dialog interaction boundary when a narrow desktop window + * would otherwise be mistaken for a touch layout. + * + * @default 'responsive' + */ + presentation?: 'responsive' | 'dialog' + /** Routes session creation through the owning runtime when provided. */ + createSession?: (characterId: string) => Promise + /** Routes session deletion through the owning runtime when provided. */ + deleteSession?: (sessionId: string) => Promise +}>(), { + presentation: 'responsive', +}) + +const showDialog = defineModel({ type: Boolean, default: false, required: false }) + /** * Bottom-sheet (mobile) / centered-modal (desktop) UI surface that lists every * chat session belonging to the current user, lets the user switch between @@ -36,9 +54,8 @@ import { useConsciousnessStore } from '../../../../stores/modules/consciousness' * updatedAt timestamp. */ -const showDialog = defineModel({ type: Boolean, default: false, required: false }) - const { isDesktop } = useBreakpoints() +const useDialogPresentation = computed(() => props.presentation === 'dialog' || isDesktop.value) const screenSafeArea = useScreenSafeArea() const { t } = useI18n() @@ -172,7 +189,10 @@ async function startNewSession() { isCreatingSession.value = true try { const characterId = activeCardId.value || 'default' - await chatSession.createSession(characterId, { setActive: true }) + if (props.createSession) + await props.createSession(characterId) + else + await chatSession.createSession(characterId, { setActive: true }) // PostHog retention denominator. We pick this call site (UI new-session // button) rather than `createSession` in the store because the store also // creates sessions for cloud-reconcile / fork / restore flows that aren't @@ -190,7 +210,10 @@ async function deleteRow(event: Event, sessionId: string) { // Stop the parent button's click — otherwise we'd switch into the session // we are about to remove and immediately need a fallback. event.stopPropagation() - await chatSession.deleteSession(sessionId) + if (props.deleteSession) + await props.deleteSession(sessionId) + else + await chatSession.deleteSession(sessionId) } // Per-open generation counter. The batch loadSession loop checks this before @@ -225,7 +248,7 @@ watch(showDialog, async (open) => {