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/api.ts b/src/api.ts index 4b323bb..5ee4594 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,7 +4,7 @@ import { auth } from './auth' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule, - ActivityCard, + ActivityCard, SurfaceMeta, SurfaceSnapshot, } from './contracts' const BASE = '' @@ -35,11 +35,13 @@ export const api = { setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)), rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)), - surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json(r)), + /** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */ + surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json(r)), submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record }) => auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)), - projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), - surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(r)), + projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), + /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ + surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(r)), me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)), // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { diff --git a/src/components/ArtifactsPanel.tsx b/src/components/ArtifactsPanel.tsx index 3fdc9ac..d570cd8 100644 --- a/src/components/ArtifactsPanel.tsx +++ b/src/components/ArtifactsPanel.tsx @@ -7,6 +7,7 @@ import { Collapsible, Icon } from '@ui' import { artifactIcon, humanSize } from '../messageparts' import type { Artifact } from '../contracts' import { groupArtifactsByName } from '../artifactGrouping' +import { surfaceKey } from '../surfaceKey' export function ArtifactsPanel() { const artifacts = useStore(s => s.artifacts) @@ -42,16 +43,19 @@ export function ArtifactsPanel() { {surfaces.length > 0 && (
生成页面
- {surfaces.map(sv => ( -
store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}> + {surfaces.filter(sv => surfaceKey(sv) != null).map(sv => { + const id = surfaceKey(sv)! + return ( +
store.openSurface(id)} title={`打开页面 ${sv.spec?.title ?? ''}`}> {sv.spec?.title || '(未命名页面)'} - 页面 · 修订 {sv.revision} · {sv.identity.pluginId} + 页面 · 修订 {sv.revision} · {sv.identity?.pluginId}
- ))} + ) + })}
)} {artifacts.length === 0 diff --git a/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx index 8bfe7bc..da8e27f 100644 --- a/src/components/PreviewPane.tsx +++ b/src/components/PreviewPane.tsx @@ -7,6 +7,7 @@ import type { Artifact } from '../contracts' import { humanSize } from '../messageparts' import { previewTabsKey } from '../previewtabs' import { store, useStore } from '../store' +import { surfaceKey } from '../surfaceKey' export function PreviewPane() { // 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。 @@ -17,7 +18,10 @@ export function PreviewPane() { const active = tabs.find(t => t.id === activeId) ?? tabs[0] const surfaces = useStore(s => s.surfaces) const surfaceSnap = active?.id.startsWith('surface:') - ? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id) + ? surfaces.find(x => { + const id = surfaceKey(x) + return id != null && 'surface:' + id === active.id + }) : undefined const art = active ? artifacts.find(a => a.id === active.id) : undefined const textKind = !!art && isTextKind(art) @@ -56,7 +60,7 @@ export function PreviewPane() {
{surfaceSnap - ? store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => { + ? store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => { const art = artifacts.find(a => a.id === docId) if (art) store.requestPreview(art) } }} /> diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 0d72972..60fbd78 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -6,6 +6,7 @@ import type { Run } from '../contracts' import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui' import { Icon, SurfaceView } from '@ui' import { previewTabsKey } from '../previewtabs' +import { surfaceKey } from '../surfaceKey' import { ArtifactsPanel } from './ArtifactsPanel' import { PreviewPane } from './PreviewPane' @@ -91,6 +92,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 +105,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 c99f0d9..32e89b8 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' import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement' @@ -404,23 +405,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) @@ -547,10 +556,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 }) @@ -906,7 +915,8 @@ export const store = { }, async tidyCards() { - const done = state.cards.filter(c => c.status === 'done') + // U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。 + const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger') for (const c of done) { try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ } } @@ -947,7 +957,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({ @@ -961,7 +971,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/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([]) + }) +}) 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], + ]) + }) +}) diff --git a/test/workbench-progress.test.ts b/test/workbench-progress.test.ts new file mode 100644 index 0000000..294b87a --- /dev/null +++ b/test/workbench-progress.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { hasActiveProgressCard } from '../src/components/Workbench' +import type { ActivityCard } from '@ui' + +function card(over: Partial): ActivityCard { + return { + id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1', + kind: 'progress', title: '材料整理', status: 'active', tone: 'info', + createdAt: '2026-10-12T10:00:00Z', updatedAt: '2026-10-12T10:00:00Z', + ...over, + } +} + +describe('hasActiveProgressCard', () => { + it('active/action_required progress 卡视为占用当前任务位', () => { + expect(hasActiveProgressCard([card({ status: 'active' })])).toBe(true) + expect(hasActiveProgressCard([card({ status: 'action_required' })])).toBe(true) + }) + + it('完成/失败 progress 或非 progress 不隐藏 CurrentTaskCard', () => { + expect(hasActiveProgressCard([card({ status: 'done', tone: 'ok' })])).toBe(false) + expect(hasActiveProgressCard([card({ status: 'failed', tone: 'danger' })])).toBe(false) + expect(hasActiveProgressCard([card({ kind: 'confirm', status: 'action_required' })])).toBe(false) + expect(hasActiveProgressCard([])).toBe(false) + }) +})