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
This commit is contained in:
RainbowBird
2026-07-27 02:01:10 +08:00
parent ce65bcd7f5
commit 96d0dbbb95
5 changed files with 350 additions and 16 deletions
@@ -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()
</script>
<template>
@@ -20,7 +23,12 @@ const sessionsDrawerOpen = shallowRef(false)
class="interaction-area block"
h-full w-full p-4 transition="opacity duration-250"
/>
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
<ChatSessionsDrawer
v-model="sessionsDrawerOpen"
presentation="dialog"
:create-session="chatSync.requestCreateSession"
:delete-session="chatSync.requestDeleteSession"
/>
</div>
</template>
@@ -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<string>
sessionMessages: Ref<Record<string, MockChatMessage[]>>
sessionMetas: Ref<Record<string, unknown>>
sessionMetas: Ref<Record<string, ChatSessionMeta>>
applyRemoteSnapshot: ReturnType<typeof vi.fn>
createSession: ReturnType<typeof vi.fn>
deleteSession: ReturnType<typeof vi.fn>
setSessionMessages: ReturnType<typeof vi.fn>
setActiveSession: ReturnType<typeof vi.fn>
getSessionMessages: ReturnType<typeof vi.fn>
importSessions: MockImportSessions
ingest: ReturnType<typeof vi.fn>
@@ -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<Record<string, MockChatMessage[]>>({
'session-1': [{ role: 'system', content: 'init' }],
})
const sessionMetas = ref<Record<string, unknown>>({})
const sessionMetas = ref<Record<string, ChatSessionMeta>>({
'session-1': {
sessionId: 'session-1',
userId: 'local',
characterId: 'default',
createdAt: 1,
updatedAt: 1,
},
})
const applyRemoteSnapshot = vi.fn((snapshot: {
activeSessionId: string
sessionMessages: Record<string, MockChatMessage[]>
sessionMetas: Record<string, unknown>
sessionMetas: Record<string, ChatSessionMeta>
}) => {
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<void>>().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'] = [
@@ -59,6 +59,10 @@ interface SpotlightIngestResult {
visibleText: string
}
interface CreateSessionResult {
sessionId: string
}
interface ChatCommandMessage<C extends string = string, P = unknown> {
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<ToolsetId>>
| 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<CreateSessionResult>({
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<void>({
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,
}
})
@@ -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<string>
deleteSession?: (sessionId: string) => Promise<void>
}) {
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')
})
})
@@ -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<string>
/** Routes session deletion through the owning runtime when provided. */
deleteSession?: (sessionId: string) => Promise<void>
}>(), {
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) => {
</script>
<template>
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
<DialogRoot v-if="useDialogPresentation" :open="showDialog" @update:open="value => showDialog = value">
<slot name="trigger" />
<DialogPortal>
<DialogOverlay