diff --git a/src/store.ts b/src/store.ts index 12be84b..ccb8cff 100644 --- a/src/store.ts +++ b/src/store.ts @@ -817,7 +817,7 @@ export const store = { } // U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。 // surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。 - api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {}) + if (state.currentProjectId) api.projectSurfaces(state.currentProjectId).then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {}) api.surfaceList(convId).then(async list => { if (state.currentConvId !== convId || conversationEpoch !== epoch) return const metas = list ?? [] diff --git a/test/open-conversation-no-project.test.ts b/test/open-conversation-no-project.test.ts new file mode 100644 index 0000000..759027e --- /dev/null +++ b/test/open-conversation-no-project.test.ts @@ -0,0 +1,64 @@ +// ps-frontend-no-project-surfaces-r2:openConversation 在 state.currentProjectId 为空时 +// 不再触发 /api/projects//surfaces 请求(避免被移除项目成员打开历史会话时的 404 噪音)。 +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { api } from '../src/api' +import { store } from '../src/store' +import type { Conversation, Message, SurfaceMeta } from '../src/contracts' + +function detail(id: string) { + return { + conversation: { id, projectId: '', title: id, createdAt: '', updatedAt: '' } as Conversation, + messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0, + } +} +class FakeEventSource { + onopen = null + onerror = null + onmessage = null + addEventListener() {} + close() {} +} + +let projectSurfacesSpy: ReturnType + +beforeEach(async () => { + vi.stubGlobal('EventSource', FakeEventSource) + vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id)) + vi.spyOn(api, 'getPlan').mockResolvedValue(null) + vi.spyOn(api, 'getTodos').mockResolvedValue(null) + vi.spyOn(api, 'listCards').mockResolvedValue([]) + vi.spyOn(api, 'listTasks').mockResolvedValue([]) + vi.spyOn(api, 'listArtifacts').mockResolvedValue([]) + vi.spyOn(api, 'surfaceList').mockResolvedValue([]) + projectSurfacesSpy = vi.spyOn(api, 'projectSurfaces').mockResolvedValue([]) + vi.spyOn(api, 'listProjectCards').mockResolvedValue([]) + // 还原至 currentProjectId = null 的干净投影,避免别的套件残留影响本次断言。 + store.resetIdentity() +}) +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +describe('openConversation projectSurfaces gating', () => { + it('skips the /api/projects/.../surfaces request and keeps projectSurfaces empty when no project is current', async () => { + expect(store.getState().currentProjectId).toBeNull() + await store.openConversation('cnv_no_project') + // 让微任务(包含 surfaceList/Stream 等链)落定,但不改变当前结论。 + await new Promise(r => setTimeout(r, 0)) + expect(projectSurfacesSpy).not.toHaveBeenCalled() + expect(store.getState().projectSurfaces).toEqual([]) + expect(store.getState().currentProjectId).toBeNull() + }) + + it('still requests /api/projects//surfaces when a project is current', async () => { + const metas: SurfaceMeta[] = [{ surfaceId: 'surf_1', title: '页面一', pluginId: 'plg_1', revision: 1, updatedAt: '2026-10-12T00:00:00Z' }] + projectSurfacesSpy.mockResolvedValueOnce(metas) + await store.openProject('prj_with_surfaces') + expect(store.getState().currentProjectId).toBe('prj_with_surfaces') + projectSurfacesSpy.mockClear() + await store.openConversation('cnv_with_project') + expect(projectSurfacesSpy).toHaveBeenCalledTimes(1) + expect(projectSurfacesSpy.mock.calls[0][0]).toBe('prj_with_surfaces') + // 微任务处理完 .then 后,projectSurfaces 投影被填进服务端的元数据列表。 + await new Promise(r => setTimeout(r, 0)) + expect(store.getState().projectSurfaces).toEqual(metas) + }) +})