From 1edd872c554f666ba43353da7e6f0605ecec311b Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 07:45:08 +0000 Subject: [PATCH 1/3] Prefer progress cards over duplicate CurrentTaskCard Why: When a slow-lane progress card is active, hide the plan/todos CurrentTaskCard so the work column has one authoritative task object. Evidence: npx vitest run (workbench-progress + full suite) Impact: Workbench work tab; tidyCards skips done+danger failures. Refs: fast-slow-channel-display-u1-plan W4 Co-authored-by: Cursor --- src/components/Workbench.tsx | 8 +++++++- src/store.ts | 3 ++- test/workbench-progress.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 test/workbench-progress.test.ts diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 0d72972..f6ba34f 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -91,6 +91,11 @@ function FilesTab() { return
} +/** U1:同会话存在后台 progress 卡(进行中/待确认)时,progress 为权威对象,隐藏 CurrentTaskCard 避免双标题。 */ +export function hasActiveProgressCard(cards: ActivityCard[]): boolean { + return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required')) +} + function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: { plan: Parameters[0]['plan'] todos: Parameters[0]['todos'] @@ -99,10 +104,11 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: { projectCards: ActivityCard[] cards: ActivityCard[] }) { + const showCurrentTask = !hasActiveProgressCard(cards) return ( <>
- + {showCurrentTask ? : null}
-
diff --git a/src/store.ts b/src/store.ts index d78fbbb..113485d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,6 +8,7 @@ import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessag import { canFlushOutbox, deriveShellPhase } from './runstate' import { flashElement } from './scrollsync' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' +import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' import { rdmAuditReady } from './workspaces' type FontSizeChoice = 13 | 14 | 16 | 18 @@ -397,23 +398,31 @@ export const store = { }) } // U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。 + // surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。 api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {}) - api.surfaceList(convId).then(list => { + api.surfaceList(convId).then(async list => { if (state.currentConvId !== convId) return - set({ surfaces: list ?? [] }) + const metas = list ?? [] // 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切 - // 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。 + // 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。 const conv = previewTabsKey(state.currentConvId) const tabs = state.previewTabs[conv] ?? [] const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:')) - if (!hasSurfaceTab && (list?.length ?? 0) > 0) { - const latest = list[0] + if (!hasSurfaceTab && metas.length > 0) { + const latest = metas[0] const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' }) set({ previewTabs: { ...state.previewTabs, [conv]: opened.tabs }, previewActive: { ...state.previewActive, [conv]: opened.activeId }, }) } + const fetched = await Promise.all(metas.map(m => + api.surfaceGet(String(m.surfaceId)).catch(() => null), + )) + if (state.currentConvId !== convId) return + const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null) + // 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。 + set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) }) }).catch(() => {}) const resumeSeq = await store.reconcile() store.connectLive(resumeSeq ?? state.lastSeq) @@ -540,10 +549,10 @@ export const store = { // KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫: // 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。 const p = e.payload as SurfaceSnapshot - if (!p?.identity?.surfaceId) break - const id = String(p.identity.surfaceId) - const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id) - const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id) + const id = surfaceKey(p) + if (!id) break + const surfaces = state.surfaces.filter(x => surfaceKey(x) !== id) + const existing = state.surfaces.find(x => surfaceKey(x) === id) if (!existing || p.revision > existing.revision) { surfaces.push(p) set({ surfaces }) @@ -934,7 +943,7 @@ export const store = { }, openSurface(surfaceId: string) { // generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。 - const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId) + const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId) const conv = previewTabsKey(state.currentConvId) const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' }) set({ @@ -948,7 +957,7 @@ export const store = { async handleSurfaceAction(surfaceId: string, actionId: string, data: Record) { const inflight = surfaceActionsInflight[surfaceId + ':' + actionId] if (inflight) return - const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId) + const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId) if (!snap) { store.toast('页面快照不可用,无法提交'); return } const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2)) surfaceActionsInflight[surfaceId + ':' + actionId] = true diff --git a/src/surfaceKey.ts b/src/surfaceKey.ts new file mode 100644 index 0000000..9c9b03f --- /dev/null +++ b/src/surfaceKey.ts @@ -0,0 +1,19 @@ +// surface 投影可能短暂混入无 identity 的条目;读 id 一律经此助手,避免渲染崩溃。 +import type { SurfaceSnapshot } from './contracts' + +export function surfaceKey(x: { identity?: { surfaceId?: unknown } } | null | undefined): string | null { + const id = x?.identity?.surfaceId + return id == null || id === '' ? null : String(id) +} + +/** 按 surfaceId 合并;同 id 取 revision 较大者。跳过无 identity 的脏项。 */ +export function mergeSurfacesByRevision(a: SurfaceSnapshot[], b: SurfaceSnapshot[]): SurfaceSnapshot[] { + const byId = new Map() + for (const s of [...a, ...b]) { + const id = surfaceKey(s) + if (!id) continue + const existing = byId.get(id) + if (!existing || (s.revision ?? 0) >= (existing.revision ?? 0)) byId.set(id, s) + } + return [...byId.values()] +} diff --git a/test/surfaceKey.test.ts b/test/surfaceKey.test.ts new file mode 100644 index 0000000..25e92f0 --- /dev/null +++ b/test/surfaceKey.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import type { SurfaceSnapshot } from '../src/contracts' +import { mergeSurfacesByRevision, surfaceKey } from '../src/surfaceKey' + +function snap(id: string, revision: number): SurfaceSnapshot { + return { + identity: { + surfaceId: id, + owner: 'o', + pluginId: 'p', + }, + compat: { schemaVersion: 1, catalogVersion: '1' }, + revision, + activationEpoch: 0, + spec: { title: id, nodes: [] }, + } +} + +describe('surfaceKey', () => { + it('reads nested identity and skips missing', () => { + expect(surfaceKey(snap('s1', 1))).toBe('s1') + expect(surfaceKey(undefined)).toBeNull() + expect(surfaceKey({} as SurfaceSnapshot)).toBeNull() + expect(surfaceKey({ identity: {} } as SurfaceSnapshot)).toBeNull() + }) +}) + +describe('mergeSurfacesByRevision', () => { + it('keeps higher revision and drops dirty entries', () => { + const dirty = { revision: 99 } as SurfaceSnapshot + const merged = mergeSurfacesByRevision( + [snap('a', 1), dirty], + [snap('a', 3), snap('b', 1)], + ) + expect(merged.map(s => [surfaceKey(s), s.revision])).toEqual([ + ['a', 3], + ['b', 1], + ]) + }) +}) From 3a5fbe6e426403cc438131825f39cf454ec4c2a8 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 14:20:35 +0000 Subject: [PATCH 3/3] Test saved surface recovery through conversation reload Why: Existing event and helper tests missed the SurfaceMeta to snapshot reload path that blanked the app after the integration pointer regressed. Evidence: New restore regressions fail on 1edd872 and pass on compatible main; independent reviewer replays and real browser contract replay are recorded in the Dev surface-black-screen-recovery task card. Impact: Tests only; protects reload, partial failure, newer live revisions, conversation switching, and two-page browser preview. Refs: Frontend #68; Dev #454 and #452; Dev surface-black-screen-recovery --- e2e/surface-recovery.playwright.ts | 60 ++++++++++++++++++++++ src/surface.test.ts | 80 +++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 e2e/surface-recovery.playwright.ts diff --git a/e2e/surface-recovery.playwright.ts b/e2e/surface-recovery.playwright.ts new file mode 100644 index 0000000..2b046dc --- /dev/null +++ b/e2e/surface-recovery.playwright.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test' +import type { Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts' + +test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'requires a served frontend bundle') + +const conversation: Conversation = { + id: 'surface-reload', projectId: 'surface-project', title: '页面恢复回归', + createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z', +} +const snapshots: SurfaceSnapshot[] = ['page-1', 'page-2'].map((surfaceId, i) => ({ + identity: { surfaceId, owner: 'fixture', pluginId: 'generative-ui', conversationId: conversation.id }, + compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1, + spec: { title: `恢复页面${i + 1}`, nodes: [{ id: 'body', type: 'markdown', markdown: `恢复正文${i + 1}` }] }, +})) +const metadata: SurfaceMeta[] = snapshots.map(s => ({ + surfaceId: s.identity.surfaceId, title: s.spec.title, pluginId: s.identity.pluginId, + revision: s.revision, conversationId: conversation.id, updatedAt: conversation.updatedAt, +})) + +test('saved-page reload keeps the app and restores both full snapshots', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', e => errors.push(e.message)) + await page.addInitScript(({ id }) => { + localStorage.setItem('as.conv', id) + localStorage.setItem('as.ui', JSON.stringify({ rightTab: 'files', filesView: 'preview', rightCollapsed: false })) + }, { id: conversation.id }) + + // Replay the two distinct server contracts through the real app. Intercept all + // API requests so this regression can run against a live bundle without writes. + await page.route('**/api/**', async route => { + const request = route.request() + const path = new URL(request.url()).pathname + if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only regression' }) + if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' }) + let body: unknown = [] + if (path === '/api/health') body = { status: 'ok' } + else if (path === '/api/me') body = { owner: 'fixture', name: 'fixture', auth: true, authVia: 'session' } + else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '恢复项目', createdAt: '', updatedAt: '' }] + else if (path === '/api/conversations' || path === `/api/projects/${conversation.projectId}/conversations`) body = [conversation] + else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 } + else if (path === `/api/conversations/${conversation.id}/surfaces` || path === `/api/projects/${conversation.projectId}/surfaces`) body = metadata + else if (path.startsWith('/api/surfaces/')) { + body = snapshots.find(s => s.identity.surfaceId === path.split('/').at(-1)) + } else if (path.startsWith('/api/settings')) body = { schema: [], defaults: {}, user: {}, project: {} } + else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }) + }) + + await page.goto('/') + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1') + await expect(page.locator('.preview-pane')).toContainText('恢复正文1') + await page.reload() + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面1') + await page.getByRole('button', { name: '返回文件列表' }).click() + await expect(page.locator('[data-surface-row]')).toHaveCount(2) + await page.locator('[data-surface-row="page-2"]').click() + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText('恢复页面2') + await expect(page.locator('.preview-pane')).toContainText('恢复正文2') + expect(errors).toEqual([]) +}) diff --git a/src/surface.test.ts b/src/surface.test.ts index 5347206..158c228 100644 --- a/src/surface.test.ts +++ b/src/surface.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as apiModule from './api' +import { api } from './api' import { store } from './store' import { previewTabsKey } from './previewtabs' -import type { StreamEvent, SurfaceSnapshot } from './contracts' +import type { StreamEvent, SurfaceMeta, SurfaceSnapshot } from './contracts' // U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订; // 服务端快照是唯一权威(KTD8 单一状态所有者)。 @@ -75,3 +77,77 @@ describe('U6 工作区任务页', () => { expect(store.getState().ui.workspaceSurfacePin).toBeNull() }) }) + +describe('saved surfaces on conversation reload', () => { + const snapshot = (id: string, revision = 1) => surfEvent(1, id, revision, id).payload! + const meta = (id: string): SurfaceMeta => ({ + surfaceId: id, title: id, pluginId: 'generative-ui', revision: 1, + conversationId: 'c1', updatedAt: '2026-10-12T00:00:00Z', + }) + + beforeEach(() => { + vi.spyOn(api, 'projectSurfaces').mockResolvedValue([]) + vi.spyOn(api, 'surfaceList').mockResolvedValue([]) + vi.spyOn(api, 'surfaceGet').mockImplementation(async id => snapshot(id)) + vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({ + conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' }, + messages: [], runs: [], activeHitl: [], lastSeq: 0, + })) + vi.spyOn(api, 'getPlan').mockResolvedValue(null) + vi.spyOn(api, 'getTodos').mockResolvedValue(null) + vi.spyOn(api, 'listCards').mockResolvedValue([]) + vi.spyOn(api, 'listTasks').mockResolvedValue([]) + vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} }) + store.setUI({ workspaceSurfacePin: null }) + const key = previewTabsKey(store.getState().currentConvId) + for (const tab of store.getState().previewTabs[key] ?? []) store.closePreview(tab.id) + }) + + afterEach(() => vi.restoreAllMocks()) + + it('hydrates the metadata directory before exposing two renderable snapshots', async () => { + vi.mocked(api.surfaceList).mockResolvedValue([meta('reload-1'), meta('reload-2')]) + await store.openConversation('reload') + await vi.waitFor(() => { + expect(store.getState().surfaces).toEqual([snapshot('reload-1'), snapshot('reload-2')]) + }) + expect(api.surfaceGet).toHaveBeenCalledWith('reload-1') + expect(api.surfaceGet).toHaveBeenCalledWith('reload-2') + expect(store.getState().previewActive.reload).toBe('surface:reload-1') + }) + + it('isolates one unavailable page while restoring the remaining page', async () => { + vi.mocked(api.surfaceList).mockResolvedValue([meta('missing'), meta('available')]) + vi.mocked(api.surfaceGet).mockImplementation(async id => { + if (id === 'missing') throw new Error('404: removed page') + return snapshot(id) + }) + await store.openConversation('partial') + await vi.waitFor(() => expect(store.getState().surfaces).toEqual([snapshot('available')])) + }) + + it('keeps a newer live revision when an older snapshot arrives late', async () => { + let resolve!: (value: SurfaceSnapshot) => void + vi.mocked(api.surfaceList).mockResolvedValue([meta('racing')]) + vi.mocked(api.surfaceGet).mockReturnValue(new Promise(r => { resolve = r })) + await store.openConversation('race') + store.applyEvent(surfEvent(301, 'racing', 2, 'live revision')) + resolve(snapshot('racing', 1)) + await new Promise(r => setTimeout(r, 0)) + expect(store.getState().surfaces).toHaveLength(1) + expect(store.getState().surfaces[0].revision).toBe(2) + expect(store.getState().surfaces[0].spec.title).toBe('live revision') + }) + + it('discards hydration from the previous conversation after switching', async () => { + let resolve!: (value: SurfaceSnapshot) => void + vi.mocked(api.surfaceList).mockResolvedValueOnce([meta('previous')]).mockResolvedValue([]) + vi.mocked(api.surfaceGet).mockReturnValue(new Promise(r => { resolve = r })) + await store.openConversation('previous-conv') + await store.openConversation('current-conv') + resolve(snapshot('previous')) + await new Promise(r => setTimeout(r, 0)) + expect(store.getState().currentConvId).toBe('current-conv') + expect(store.getState().surfaces).toEqual([]) + }) +})