Deliver a conversation-first progressive workbench with safe live state

Why: make material, background work, confirmations and files accessible to nontechnical users across desktop and mobile.

Evidence: Dev SELF receipts for Frontend tsc, 190 tests, knip baseline, build, nine fixture browser cases and two real HTTP/browser smoke cases using MockRunner.

Impact: existing API and permissions remain authoritative; stale events/queries and duplicate effects are guarded, while one file preview and reading state are preserved.

Refs: work-companion-ui design r0.2 and plan r1 U2-U5; UI01-UI13.
This commit is contained in:
2026-10-12 16:05:13 +00:00
parent 4965de269a
commit 411a79d9e2
22 changed files with 1847 additions and 276 deletions
+257
View File
@@ -0,0 +1,257 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
return { promise, resolve, reject }
}
function card(id = 'task', status: ActivityCard['status'] = 'active', conversationId = 'a'): ActivityCard {
return { id, status, conversationId, projectId: 'p', kind: 'progress', title: '整理材料', createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' }
}
function detail(id: string, runs: Run[] = [], lastSeq = 0, resumeSeq = lastSeq) {
return { conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation, messages: [] as Message[], runs, activeHitl: [], lastSeq, resumeSeq }
}
function event(seq: number, type: StreamEvent['type'], payload: unknown, runId?: string, conversationId = 'a'): StreamEvent {
return { seq, type, payload, runId, conversationId, ts: '2026-10-12T00:00:00Z', v: 1 }
}
class FakeEventSource {
onopen = null
onerror = null
onmessage = null
addEventListener() {}
close() {}
}
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([])
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
await store.openProject('p')
await store.openConversation('a')
await store.refreshCards('a')
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('work projection ordering and context', () => {
it('does not regress a finished card on an older or duplicated event', () => {
store.applyEvent(event(10, 'card.created', { card: card() }))
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'done' }))
store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'active' }))
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'failed' }))
expect(store.getState().cards[0].status).toBe('done')
})
it('rejects late card events from a different conversation', async () => {
await store.openConversation('b')
store.applyEvent(event(10, 'card.created', { card: card('foreign') }))
expect(store.getState().cards).toEqual([])
})
it('does not let a card query finish in another conversation', async () => {
const query = deferred<ActivityCard[]>()
vi.mocked(api.listCards).mockImplementation(id => id === 'a' ? query.promise : Promise.resolve([]))
const pending = store.refreshCards('a')
await store.openConversation('b')
query.resolve([card('old')])
await pending
expect(store.getState().cards).toEqual([])
})
it('keeps card events newer than an in-flight card snapshot', async () => {
store.applyEvent(event(10, 'card.created', { card: card() }))
const query = deferred<ActivityCard[]>()
vi.mocked(api.listCards).mockReturnValueOnce(query.promise)
const pending = store.refreshCards('a')
store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'done' }))
store.applyEvent(event(12, 'card.created', { card: card('new-task') }))
query.resolve([card()])
await pending
expect(store.getState().cards.find(c => c.id === 'task')?.status).toBe('done')
expect(store.getState().cards.some(c => c.id === 'new-task')).toBe(true)
})
it('preserves a status event that arrives before its first card snapshot', async () => {
const query = deferred<ActivityCard[]>()
vi.mocked(api.listCards).mockReturnValueOnce(query.promise)
const pending = store.refreshCards('a')
store.applyEvent(event(1, 'card.status', { cardId: 'task', status: 'done' }))
query.resolve([card()])
await pending
expect(store.getState().cards[0].status).toBe('done')
})
it('does not let a delayed conversation snapshot replace the newly selected one', async () => {
const query = deferred<ReturnType<typeof detail>>()
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
const pending = store.reconcile()
await store.openConversation('b')
query.resolve({ ...detail('a'), messages: [{ id: 'old-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' }] })
await pending
expect(store.getState().currentConvId).toBe('b')
expect(store.getState().messages).toEqual([])
})
it('restores foreground output through replay without replaying old card states', async () => {
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 10, 4))
vi.mocked(api.listCards).mockResolvedValue([card('task', 'done')])
await store.openConversation('a')
await store.refreshCards('a')
store.applyEvent(event(5, 'message.delta', { index: 0, text: '已经整理' }, 'front'))
store.applyEvent(event(6, 'card.status', { cardId: 'task', status: 'active' }))
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '已经整理' })
expect(store.getState().cards[0].status).toBe('done')
})
it('does not duplicate replayed foreground text', async () => {
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 0))
await store.openConversation('a')
store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front'))
store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front'))
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '结论' })
})
it('background notes do not create a foreground turn or block the composer', () => {
store.applyEvent(event(1, 'agent.progress', { agentRun: 'background', note: '正在读取' }, 'background'))
expect(store.getState().live).toBeNull()
})
it('new task pages do not replace an open document or reopen a hidden right pane', () => {
store.requestPreview({ id: 'doc', projectId: 'p', name: '需求.md', kind: 'markdown', size: 0, createdAt: '', updatedAt: '' })
store.setUI({ rightCollapsed: true })
const snap: SurfaceSnapshot = { identity: { surfaceId: 'page', pluginId: 'generative-ui', owner: 'o' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新成果', nodes: [] } }
store.applyEvent(event(1, 'surface.updated', snap, 'background'))
expect(store.getState().previewActive.a).toBe('doc')
expect(store.getState().ui.rightCollapsed).toBe(true)
expect(store.getState().surfaces[0].identity.surfaceId).toBe('page')
})
it('resets project event cursor when changing projects and drops delayed project lists', async () => {
await store.openProject('p')
store.applyProjectEvent(event(100, 'card.created', { card: { ...card('p-card'), conversationId: undefined } }))
const query = deferred<ActivityCard[]>()
vi.mocked(api.listProjectCards).mockReturnValueOnce(query.promise)
const pending = store.loadProjectCards()
await store.openProject('q')
store.applyProjectEvent(event(1, 'card.created', { card: { ...card('q-card'), projectId: 'q', conversationId: undefined } }))
query.resolve([{ ...card('old-p'), conversationId: undefined }])
await pending
expect(store.getState().projectCards.map(c => c.id)).toEqual(['q-card'])
})
})
describe('shared action feedback', () => {
it('does not append another conversation receipt to the currently open conversation', async () => {
const foreign = { ...card('other-confirm', 'action_required', 'b'), kind: 'confirm' as const }
store.applyProjectEvent(event(1, 'card.created', { card: foreign }))
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...foreign, status: 'resolved' }, receipt: {
id: 'foreign-receipt', conversationId: 'b', role: 'system', createdAt: '', parts: [{ type: 'text', text: 'other conversation outcome' }],
} })
await store.cardAction(foreign, 'confirm')
expect(store.getState().messages).toEqual([])
expect(store.getState().projectCards[0].status).toBe('resolved')
})
it('unlocks a completed read-only recheck even while confirmation remains pending', async () => {
const confirm = { ...card('recheck', 'action_required'), kind: 'confirm' as const }
store.upsertCard(confirm)
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: confirm })
await store.cardAction(confirm, 'recheck')
expect(store.getState().cardActionStates.recheck).toBeUndefined()
})
it('does not overwrite a newer resolved event with the older action response', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
store.upsertCard(confirm)
const response = deferred<{ card: ActivityCard }>()
vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
vi.mocked(api.listCards).mockResolvedValue([confirm])
const pending = store.cardAction(confirm, 'confirm')
store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' }))
response.resolve({ card: confirm })
await pending
expect(store.getState().cards[0].status).toBe('resolved')
})
it('shares one lock across a raw HITL and its activity-card projection', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard
store.upsertCard(confirm)
const response = deferred<{ resolved: boolean; pivoted: boolean; pivotText: string }>()
const request = vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise)
const first = store.resolveHITL('ask', { choiceId: 'yes' })
const second = store.cardAction(confirm, 'yes')
expect(store.getState().cardActionStates.confirm).toBe('submitting')
expect(request).toHaveBeenCalledTimes(1)
vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'resolved' }])
response.resolve({ resolved: true, pivoted: false, pivotText: '' })
await Promise.all([first, second])
expect(store.getState().cardActionStates.confirm).toBeUndefined()
})
it('uses the server cancellation outcome rather than labelling every resolved HITL executed', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard
store.upsertCard(confirm)
vi.spyOn(api, 'resolveHITL').mockResolvedValue({ resolved: true, pivoted: false, pivotText: '' })
vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'dismissed' }])
await store.resolveHITL('ask', { choiceId: 'no' })
expect(store.getState().cards[0].status).toBe('dismissed')
})
it('leaves a new conversation untouched when an older send returns', async () => {
const response = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
vi.spyOn(api, 'sendMessage').mockReturnValue(response.promise)
const pending = store.sendMessage('整理材料', [])
await store.openConversation('b')
response.resolve({ message: { id: 'old-message', conversationId: 'a', role: 'user', createdAt: '', parts: [{ type: 'text', text: '整理材料' }] }, run: { id: 'old-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } })
await pending
expect(store.getState().messages).toEqual([])
expect(store.getState().live).toBeNull()
})
it('locks a card before awaiting so two views submit one effect', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, actions: [{ id: 'confirm', label: '生成草稿' }] }
const response = deferred<{ card: ActivityCard }>()
const calls = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
store.upsertCard(confirm)
const first = store.cardAction(confirm, 'confirm')
const second = store.cardAction(confirm, 'confirm')
expect(store.getState().cardActionStates?.confirm).toBe('submitting')
expect(calls).toHaveBeenCalledTimes(1)
response.resolve({ card: { ...confirm, status: 'resolved' } })
await Promise.all([first, second])
expect(store.getState().cards[0].status).toBe('resolved')
expect(store.getState().cardActionStates?.confirm).toBeUndefined()
})
it('keeps an uncertain effect disabled until a server outcome is observed', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
store.upsertCard(confirm)
vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost'))
vi.mocked(api.listCards).mockResolvedValue([confirm])
await store.cardAction(confirm, 'confirm')
expect(store.getState().cardActionStates?.confirm).toBe('unknown')
store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' }))
expect(store.getState().cardActionStates?.confirm).toBeUndefined()
})
it('does not write a completed old action into a different conversation', async () => {
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
const response = deferred<{ card: ActivityCard }>()
vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
const pending = store.cardAction(confirm, 'confirm')
await store.openConversation('b')
response.resolve({ card: { ...confirm, status: 'resolved' } })
await pending
expect(store.getState().cards).toEqual([])
})
})