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()
@@ -20,7 +23,12 @@ const sessionsDrawerOpen = shallowRef(false)
class="interaction-area block"
h-full w-full p-4 transition="opacity duration-250"
/>
-
+
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) => {
- showDialog = value">
+ showDialog = value">