From 37c668dbada443aa68d96a7ed50d6800437d6487 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 18:55:55 +0000 Subject: [PATCH] Recover terminal work and preserve explicit navigation intent Why: Background frames blocked terminal reconciliation, stale failures left confirmations busy, and delayed page hydration changed mobile navigation. Evidence: Review findings #1/#2/#3 have recorded red-to-green checks; all 210 Frontend tests, tsc and knip pass, plus six real navigation browser checks. Impact: Keep sequence high-water and operation ownership, merge observations by object, and use an explicit preview-opening intent. Refs: ce-code-review 20261012-165912-cb241b46 #1/#2/#3, UI04/UI09/UI10/UI11 --- e2e/work-companion-navigation.playwright.ts | 141 +++++++++++++++++ src/App.tsx | 6 +- src/store.ts | 134 +++++++++++++--- test/work-state.test.ts | 166 +++++++++++++++++++- 4 files changed, 419 insertions(+), 28 deletions(-) create mode 100644 e2e/work-companion-navigation.playwright.ts diff --git a/e2e/work-companion-navigation.playwright.ts b/e2e/work-companion-navigation.playwright.ts new file mode 100644 index 0000000..de01844 --- /dev/null +++ b/e2e/work-companion-navigation.playwright.ts @@ -0,0 +1,141 @@ +import { expect, test, type Page } from '@playwright/test' +import type { Artifact, Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts' + +test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview') + +const stamp = '2026-10-12T10:00:00Z' +const conversation: Conversation = { + id: 'navigation-conversation', projectId: 'navigation-project', title: '继续阅读材料', + createdAt: stamp, updatedAt: stamp, +} +const artifact: Artifact = { + id: 'navigation-document', projectId: conversation.projectId, name: '材料摘要.md', kind: 'markdown', + size: 64, createdAt: stamp, updatedAt: stamp, +} +const snapshot: SurfaceSnapshot = { + identity: { surfaceId: 'saved-navigation-page', owner: 'reader', pluginId: 'generative-ui', conversationId: conversation.id }, + compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1, + spec: { title: '已保存的材料页面', nodes: [ + { id: 'summary', type: 'markdown', markdown: '已保存的材料范围' }, + { id: 'source', type: 'doc-ref', docId: artifact.id }, + ] }, +} +const metadata: SurfaceMeta = { + surfaceId: snapshot.identity.surfaceId, title: snapshot.spec.title, pluginId: snapshot.identity.pluginId, + revision: snapshot.revision, conversationId: conversation.id, updatedAt: stamp, +} + +// Delay the external list contract, while running the real store, App and panes. +// The saved selection is restored by openConversation rather than test-only state injection. +async function fixture(page: Page) { + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + let release!: () => void + const hydration = new Promise(resolve => { release = resolve }) + await page.addInitScript(({ conversationId }) => { + localStorage.setItem('as.conv', conversationId) + localStorage.setItem('as.ui', JSON.stringify({ + theme: 'light', rightTab: 'files', filesView: 'preview', rightCollapsed: true, + })) + }, { conversationId: conversation.id }) + 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 navigation fixture' }) + if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' }) + let body: unknown = [] + if (path === '/api/health') body = { ok: true } + else if (path === '/api/me') body = { owner: 'reader', name: '材料阅读者', auth: true, authVia: 'session', isAdmin: false } + else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '材料项目', createdAt: stamp, updatedAt: stamp }] + else if (path === '/api/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`) { await hydration; body = [metadata] } + else if (path === `/api/surfaces/${snapshot.identity.surfaceId}`) body = snapshot + else if (path === `/api/conversations/${conversation.id}/cards`) body = { cards: [] } + else if (path === `/api/projects/${conversation.projectId}/artifacts`) body = [artifact] + else if (path === `/api/artifacts/${artifact.id}/content`) { + return route.fulfill({ contentType: 'text/markdown', body: '# 材料摘要\n\n原始材料的阅读范围' }) + } else if (path === '/api/settings') body = { user: {}, project: {}, effective: {}, schema: [] } + else if (path === '/api/workspaces') body = [{ id: 'navigation-materials', title: '材料工作区', + body: [{ type: 'doc-ref', docId: artifact.id }] }] + else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }) + }) + const listRequest = page.waitForRequest(request => new URL(request.url()).pathname === `/api/conversations/${conversation.id}/surfaces`) + await page.goto('/') + await listRequest + await expect(page.locator('.chat-title')).toHaveText(conversation.title) + return { release, errors } +} + +for (const width of [736, 390]) { + test(`keeps context and focus when saved Surface hydration arrives at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }) + const state = await fixture(page) + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + const search = page.getByRole('textbox', { name: '搜索本项目会话', exact: true }) + await search.fill('材料') + await expect(search).toBeFocused() + state.release() + // The real hidden preview proves both restored tab selection and snapshot hydration finished. + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!) + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'context') + await expect(page.locator('.left-nav')).toBeVisible() + await expect(page.locator('.main')).toBeHidden() + await expect(page.locator('.right-panel')).toBeHidden() + await expect(search).toHaveValue('材料') + await expect(search).toBeFocused() + expect(state.errors).toEqual([]) + }) + + test(`preserves explicit work, Surface, document and return navigation at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }) + const state = await fixture(page) + state.release() + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!) + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await page.getByRole('button', { name: '打开工作', exact: true }).click() + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'work') + await expect(page.locator('.right-panel')).toBeVisible() + await expect(page.locator('.left-nav')).toBeHidden() + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await page.getByRole('button', { name: '打开文件', exact: true }).click() + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files') + await page.getByRole('button', { name: '返回文件列表', exact: true }).click() + await page.locator(`[data-surface-row="${snapshot.identity.surfaceId}"]`).click() + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!) + // Surface doc-ref uses the existing document bridge, which opens the same preview. + await page.locator('.preview-pane').getByRole('button', { name: artifact.id, exact: true }).click() + await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name) + await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围') + await page.getByRole('button', { name: '展开预览', exact: true }).click() + await expect(page.locator('.right-panel')).toHaveClass(/expanded/) + await page.getByRole('button', { name: '返回对话', exact: true }).click() + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation') + await expect(page.locator('.main')).toBeVisible() + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await page.setViewportSize({ width: 1440, height: 900 }) + await expect(page.locator('.main')).toBeVisible() + await page.setViewportSize({ width, height: 900 }) + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation') + await expect(page.locator('.left-nav')).toBeHidden() + expect(state.errors).toEqual([]) + }) + + test(`opens a workspace document while context overlays the selected files pane at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }) + const state = await fixture(page) + state.release() + await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!) + await page.getByRole('button', { name: '打开文件', exact: true }).click() + await page.getByRole('button', { name: '打开上下文', exact: true }).click() + await page.locator('.left-nav').getByRole('button', { name: /材料工作区/ }).click() + const workspace = page.getByRole('dialog', { name: '材料工作区', exact: true }) + await workspace.getByRole('button', { name: artifact.name, exact: true }).click() + await page.keyboard.press('Escape') + await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files') + await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name) + await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围') + expect(state.errors).toEqual([]) + }) +} diff --git a/src/App.tsx b/src/App.tsx index 8e1e186..6cf56a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,7 +30,7 @@ export function App() { // 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开 // 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。 const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0) - const previewActive = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null) + const previewOpenIntent = useStore(s => s.previewOpenIntent) const rightTab = useStore(s => s.ui.rightTab) const toast = useStore(s => s.toast) const trackerRunId = useStore(s => s.ui.trackerRunId) @@ -50,8 +50,8 @@ export function App() { return () => window.removeEventListener('resize', onResize) }, []) - // An explicit file/work opening leaves mobile context. Card updates do not change this choice. - useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, previewActive, ui.filesView]) + // Explicit openings leave mobile context even if pane values match; metadata restores keep the reading choice. + useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, ui.filesView, ui.previewExpanded, previewOpenIntent]) useEffect(() => { if (!layout.compact) setContextOpen(false) }, [layout.compact]) const returnToConversation = () => { diff --git a/src/store.ts b/src/store.ts index 4f704e9..26c8f8b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -136,6 +136,8 @@ interface AppState { outboxDraining: boolean previewTabs: Record previewActive: Record + /** Ephemeral explicit opening intent; restoring saved metadata must not advance it. */ + previewOpenIntent: number } const DEFAULT_UI: UIState = { @@ -194,7 +196,7 @@ let state: AppState = { adminSections: [], ui: loadUI(), connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, - previewTabs: {}, previewActive: {}, + previewTabs: {}, previewActive: {}, previewOpenIntent: 0, } const listeners = new Set<() => void>() @@ -279,6 +281,12 @@ let projectSeq = 0 let conversationEpoch = 0 let streamSeq = 0 let detailRequest = 0 +let detailSeq = 0 +let detailVersion = 0 +type DetailObservation = { version: number; seq: number; terminalSeq?: number; terminalVersion?: number } +let runObservations: Record = {} +let messageObservations: Record = {} +let hitlObservations: Record = {} let cardsRequest = 0 let projectCardsRequest = 0 let cardVersion = 0 @@ -288,6 +296,41 @@ let projectCardVersions: Record = {} let cardPatches: Record> = {} let projectCardPatches: Record> = {} const actionInflight = new Set() +const actionFeedbackOwners: Record = {} + +function observeDetail(observations: Record, id: string, seq = state.lastSeq, terminal = false) { + const version = ++detailVersion + observations[id] = { ...observations[id], version, seq, + ...(terminal ? { terminalSeq: seq, terminalVersion: version } : {}) } +} + +function detailRowIsNewer(observations: Record, id: string, started: number, seq: number) { + const observation = observations[id] + return observation && (observation.version > started || observation.seq > seq) +} + +function terminalObservationIsNewer(id: string, started: number, seq: number) { + const observation = runObservations[id] + return (observation?.terminalVersion ?? 0) > started || (observation?.terminalSeq ?? 0) > seq +} + +function mergeDetailRows(snapshot: T[], current: T[], observations: Record, started: number, seq: number) { + const fresh = current.filter(row => detailRowIsNewer(observations, row.id, started, seq)) + const byId = new Map(fresh.map(row => [row.id, row])) + const snapshotIds = new Set(snapshot.map(row => row.id)) + return [...snapshot.map(row => byId.get(row.id) ?? row), ...fresh.filter(row => !snapshotIds.has(row.id))] +} + +function scheduleTerminalReconcile() { + const convId = state.currentConvId + const epoch = conversationEpoch + setTimeout(() => { + if (state.currentConvId !== convId || conversationEpoch !== epoch) return + void store.reconcile().then(() => { + if (state.currentConvId === convId && conversationEpoch === epoch) return store.flushOutbox() + }) + }, 250) +} // Snapshot queries may overlap events. Only rows changed after the request began // override that snapshot; a newer request/context invalidates the old query entirely. @@ -306,18 +349,26 @@ function hitlSource(card: ActivityCard): string | undefined { } } -function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: string[] = []) { +function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: string[] = [], owner?: symbol) { let states: AppState['cardActionStates'] | undefined for (const id of [key, ...ids]) { + if (owner && actionFeedbackOwners[id] !== owner) continue const current = states ?? state.cardActionStates if (value ? current[id] === value : !(id in current)) continue states ??= { ...state.cardActionStates } if (value) states[id] = value - else delete states[id] + else { delete states[id]; delete actionFeedbackOwners[id] } } if (states) set({ cardActionStates: states }) } +function startActionFeedback(key: string, ids: string[] = []) { + const owner = Symbol(key) + for (const id of [key, ...ids]) actionFeedbackOwners[id] = owner + actionFeedback(key, 'submitting', ids, owner) + return owner +} + function settleCardFeedback(cards: ActivityCard[]) { let states: AppState['cardActionStates'] | undefined for (const card of cards) { @@ -327,6 +378,7 @@ function settleCardFeedback(cards: ActivityCard[]) { if (!(id in (states ?? state.cardActionStates))) continue states ??= { ...state.cardActionStates } delete states[id] + delete actionFeedbackOwners[id] } } if (states) set({ cardActionStates: states }) @@ -395,6 +447,7 @@ export const store = { projectCardsRequest++ projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {} conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {} + detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {} set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, cards: [], projectCards: [], plan: null, todos: null, surfaces: [], projectSurfaces: [], artifacts: [], outbox: [], outboxDraining: false, posting: false }) @@ -470,6 +523,7 @@ export const store = { if (sse) { sse.close(); sse = null } const epoch = ++conversationEpoch streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {} + detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {} set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false }) setUI({ previewExpanded: false }) persistConvId(convId) @@ -519,16 +573,31 @@ export const store = { const convId = state.currentConvId const epoch = conversationEpoch const request = ++detailRequest + const started = detailVersion if (!convId) return try { const d = await api.conversationDetail(convId) - if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest || d.lastSeq < state.lastSeq) return - const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status) + if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest || d.lastSeq < detailSeq) return + // A background frame may overtake this snapshot without changing its + // messages/runs. Preserve only newer observations of the same object. + const messages = mergeDetailRows(d.messages, state.messages, messageObservations, started, d.lastSeq) + const snapshotRuns = new Map(d.runs.map(run => [run.id, run])) + const runs = mergeDetailRows(d.runs, state.runs, runObservations, started, d.lastSeq).map(run => { + const snapshot = snapshotRuns.get(run.id) + const newerTerminal = terminalObservationIsNewer(run.id, started, d.lastSeq) + return snapshot && isTerminal(snapshot.status) && !isTerminal(run.status) && !newerTerminal ? snapshot : run + }) + const activeHitl = mergeDetailRows(d.activeHitl, state.activeHitl, hitlObservations, started, d.lastSeq) + .filter(card => !detailRowIsNewer(hitlObservations, card.id, started, d.lastSeq) || state.activeHitl.some(c => c.id === card.id)) + const live = state.live + const liveRunActive = live && (!isTerminal(runs.find(r => r.id === live.runId)?.status) || + (terminalObservationIsNewer(live.runId, started, d.lastSeq) && !messages.some(m => m.role === 'assistant' && m.runId === live.runId))) const resumeSeq = d.resumeSeq ?? d.lastSeq + detailSeq = d.lastSeq set({ - messages: d.messages, - runs: d.runs, - activeHitl: d.activeHitl, + messages, + runs, + activeHitl, lastSeq: Math.max(state.lastSeq, d.lastSeq), live: liveRunActive ? state.live : null, connected: true, @@ -565,6 +634,7 @@ export const store = { // persistent card/plan/status snapshot already returned by the detail query. if (e.seq <= previousSeq && !liveEventTypes.has(e.type)) return set({ lastSeq: Math.max(previousSeq, e.seq) }) + if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq) const now = eventTime(e) const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now) switch (e.type) { @@ -612,11 +682,18 @@ export const store = { break } case 'hitl.request': { + const card = e.payload as HITLCard + if (card?.id) { + observeDetail(hitlObservations, card.id, e.seq) + set({ activeHitl: [...state.activeHitl.filter(c => c.id !== card.id), card] }) + } store.reconcile() break } case 'hitl.resolved': { - set({ activeHitl: state.activeHitl.filter(c => c.id !== (e.payload as { cardId: string }).cardId) }) + const cardId = (e.payload as { cardId: string }).cardId + observeDetail(hitlObservations, cardId, e.seq) + set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) }) break } case 'agent.spawn': { @@ -710,17 +787,19 @@ export const store = { break } case 'run.completed': { + if (e.runId) observeDetail(runObservations, e.runId, e.seq, true) const p = e.payload as { usage?: unknown; context?: ContextObs } if (p?.context && e.runId) { set({ contextObs: { ...state.contextObs, [e.runId]: p.context } }) } - setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250) + scheduleTerminalReconcile() break } case 'run.failed': case 'error': { + if (e.runId && e.type === 'run.failed') observeDetail(runObservations, e.runId, e.seq, true) // 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。 - setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250) + scheduleTerminalReconcile() break } default: { @@ -749,6 +828,8 @@ export const store = { } if (out.run) { const started = out.run + observeDetail(runObservations, started.id) + observeDetail(messageObservations, out.message.id) // 立刻把 run 合进列表,停止/排队不必等终态对账。已有同 id 记录保持不动。 const runs = mergeStartedRun(state.runs, started) const messages = state.messages.some(m => m.id === out.message.id) @@ -843,12 +924,12 @@ export const store = { const convId = state.currentConvId const epoch = conversationEpoch actionInflight.add(key) - actionFeedback(key, 'submitting', related) + const feedbackOwner = startActionFeedback(key, related) try { const out = await api.resolveHITL(cardId, resp) if (state.currentConvId !== convId || conversationEpoch !== epoch) return if (!out.resolved) { - actionFeedback(key, 'unknown', related) + actionFeedback(key, 'unknown', related, feedbackOwner) await store.reconcile() return } @@ -857,21 +938,21 @@ export const store = { // outcome (including cancellation) instead of stamping every choice resolved. if (convId) await store.refreshCards(convId) if (state.currentConvId !== convId || conversationEpoch !== epoch) return - actionFeedback(key) + actionFeedback(key, undefined, [], feedbackOwner) for (const id of related) { const current = [...state.cards, ...state.projectCards].find(c => c.id === id) - actionFeedback(id, current?.status === 'action_required' ? 'unknown' : undefined) + actionFeedback(id, current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner) } if (out.pivoted && out.pivotText) { await store.sendMessage(out.pivotText, []) } } catch (e) { + const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e)) + actionFeedback(key, rejected ? undefined : 'unknown', related, feedbackOwner) if (state.currentConvId !== convId || conversationEpoch !== epoch) return - if (/^4\d\d:/.test(String(e instanceof Error ? e.message : e))) { - actionFeedback(key, undefined, related) + if (rejected) { store.toast('本次确认未被接受,请查看当前事项后再处理') } else { - actionFeedback(key, 'unknown', related) store.toast('确认结果暂时未知,正在核实处理状态') await store.reconcile() } @@ -918,7 +999,7 @@ export const store = { const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch)) actionInflight.add(card.id) - actionFeedback(card.id, 'submitting') + const feedbackOwner = startActionFeedback(card.id) try { const out = await api.cardAction(card.id, actionId, freeText) if (!sameContext()) return @@ -929,7 +1010,10 @@ export const store = { const cards = state.projectCards set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] }) } - if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) set({ messages: [...state.messages, out.receipt as Message] }) + if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) { + observeDetail(messageObservations, out.receipt.id) + set({ messages: [...state.messages, out.receipt as Message] }) + } // 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。 if (!newer && card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) { if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId) @@ -937,15 +1021,15 @@ export const store = { } if (sameContext()) { const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id) - actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined) + actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner) } } catch (e) { + const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e)) + actionFeedback(card.id, rejected ? undefined : 'unknown', [], feedbackOwner) if (!sameContext()) return - if (/^4\d\d:/.test(String(e instanceof Error ? e.message : e))) { - actionFeedback(card.id) + if (rejected) { store.toast('本次操作未被接受,请查看当前事项后再处理') } else { - actionFeedback(card.id, 'unknown') store.toast('操作结果暂时未知,正在核实处理状态') if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId) else await store.loadProjectCards() @@ -1151,6 +1235,7 @@ export const store = { set({ previewTabs: { ...state.previewTabs, [conv]: opened.tabs }, previewActive: { ...state.previewActive, [conv]: opened.activeId }, + previewOpenIntent: state.previewOpenIntent + 1, }) syncFilesEntry(true) }, @@ -1162,6 +1247,7 @@ export const store = { set({ previewTabs: { ...state.previewTabs, [conv]: opened.tabs }, previewActive: { ...state.previewActive, [conv]: opened.activeId }, + previewOpenIntent: state.previewOpenIntent + 1, }) syncFilesEntry(true) }, diff --git a/test/work-state.test.ts b/test/work-state.test.ts index a62060f..d831590 100644 --- a/test/work-state.test.ts +++ b/test/work-state.test.ts @@ -41,9 +41,104 @@ beforeEach(async () => { await store.openConversation('a') await store.refreshCards('a') }) -afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) describe('work projection ordering and context', () => { + it('settles foreground completion and queued input when background events overtake its detail response', async () => { + vi.useFakeTimers() + const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } + vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run])) + await store.openConversation('a') + store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front')) + store.upsertCard(card('background')) + store.enqueueOutbox('继续整理', ['material'], ['材料']) + const query = deferred>() + vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) + const send = deferred>>() + const submission = vi.spyOn(api, 'sendMessage').mockReturnValue(send.promise) + store.applyEvent(event(2, 'run.completed', {}, 'front')) + await vi.advanceTimersByTimeAsync(250) + store.applyEvent(event(3, 'agent.progress', { agentRun: 'background', note: '仍在核对' }, 'background')) + store.applyEvent(event(4, 'card.status', { cardId: 'background', status: 'done' }, 'background')) + vi.mocked(api.listCards).mockResolvedValue([card('background', 'done')]) + query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 2), messages: [ + { id: 'reply', conversationId: 'a', runId: 'front', role: 'assistant', parts: [{ type: 'text', text: '整理完成' }], createdAt: '' }, + ] }) + await vi.advanceTimersByTimeAsync(0) + expect(store.getState().runs.find(r => r.id === 'front')?.status).toBe('completed') + expect(store.getState().messages.map(m => m.id)).toContain('reply') + expect(store.getState().live).toBeNull() + expect(store.getState().lastSeq).toBe(4) + expect(store.getState().cards.find(c => c.id === 'background')?.status).toBe('done') + expect(store.getState().outbox).toEqual([]) + expect(submission).toHaveBeenCalledWith('a', '继续整理', ['material'], expect.any(String)) + send.resolve({ message: { id: 'queued', conversationId: 'a', role: 'user', parts: [], createdAt: '' } }) + await vi.advanceTimersByTimeAsync(0) + }) + + it('waits for the newer terminal run snapshot before settling its live output', async () => { + vi.useFakeTimers() + const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } + vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run])) + await store.openConversation('a') + store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front')) + const query = deferred>() + vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) + const pending = store.reconcile() + store.applyEvent(event(2, 'run.failed', { code: 'failed', message: '未完成' }, 'front')) + query.resolve(detail('a', [{ ...run, status: 'completed' }], 1)) + await pending + expect(store.getState().runs[0].status).toBe('running') + expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '正在整理' }) + vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [{ ...run, status: 'failed' }], 2)) + await vi.advanceTimersByTimeAsync(250) + expect(store.getState().runs[0].status).toBe('failed') + expect(store.getState().live).toBeNull() + }) + + it('does not restore a resolved HITL from a detail snapshot overtaken by its resolution', async () => { + const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } + vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run])) + await store.openConversation('a') + const query = deferred>>() + vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) + const pending = store.reconcile() + store.applyEvent(event(2, 'hitl.resolved', { cardId: 'ask' }, 'front')) + query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 1), activeHitl: [ + { id: 'ask', runId: 'front', conversationId: 'a', question: '继续?', kind: 'confirm', allowFreeInput: false, createdAt: '', resolved: false }, + ] }) + await pending + expect(store.getState().activeHitl).toEqual([]) + expect(store.getState().runs[0].status).toBe('completed') + expect(store.getState().lastSeq).toBe(2) + }) + + it('preserves a run and user message started after a detail query began', async () => { + const query = deferred>() + vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) + const pending = store.reconcile() + vi.spyOn(api, 'sendMessage').mockResolvedValue({ + message: { id: 'new-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' }, + run: { id: 'new-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }, + }) + await store.sendMessage('继续', []) + query.resolve(detail('a')) + await pending + expect(store.getState().runs.map(r => r.id)).toEqual(['new-run']) + expect(store.getState().messages.map(m => m.id)).toEqual(['new-message']) + expect(store.getState().live?.runId).toBe('new-run') + }) + + it('drops a terminal reconciliation timer after leaving its conversation', async () => { + vi.useFakeTimers() + store.applyEvent(event(1, 'run.completed', {}, 'front')) + await store.openConversation('b') + const calls = vi.mocked(api.conversationDetail).mock.calls.length + await vi.advanceTimersByTimeAsync(250) + expect(api.conversationDetail).toHaveBeenCalledTimes(calls) + expect(store.getState().currentConvId).toBe('b') + }) + 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' })) @@ -153,6 +248,75 @@ describe('work projection ordering and context', () => { }) describe('shared action feedback', () => { + it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => { + const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const, + ...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) } + store.upsertCard(confirm) + const response = deferred() + const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise) + const pending = store.cardAction(confirm, 'confirm') + await store.openConversation('b') + response.reject(new Error('400: rejected')) + await pending + expect(store.getState().cardActionStates[confirm.id]).toBeUndefined() + expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined() + expect(store.getState().cards).toEqual([]) + expect(store.getState().messages).toEqual([]) + vi.mocked(api.listCards).mockResolvedValue([confirm]) + await store.openConversation('a') + await store.refreshCards('a') + await store.cardAction(confirm, 'confirm') + expect(request).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['card', new TypeError('connection lost')], ['card', new Error('503: unavailable')], + ['hitl', new TypeError('connection lost')], ['hitl', new Error('503: unavailable')], + ] as const)('retains unknown feedback for an off-context %s request with %s', async (kind, failure) => { + const id = `unknown-${kind}-${failure instanceof TypeError ? 'network' : 'server'}` + const confirm = { ...card(id, 'action_required'), kind: 'confirm' as const, + ...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:${id}`]] as [string, string][] }] } : {}) } + store.upsertCard(confirm) + const response = deferred() + const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise) + const pending = store.cardAction(confirm, 'confirm') + await store.openConversation('b') + response.reject(failure) + await pending + expect(store.getState().cardActionStates[confirm.id]).toBe('unknown') + if (kind === 'hitl') expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBe('unknown') + vi.mocked(api.listCards).mockResolvedValue([confirm]) + await store.openConversation('a') + await store.refreshCards('a') + await store.cardAction(confirm, 'confirm') + expect(request).toHaveBeenCalledTimes(1) + store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id })) + expect(store.getState().cardActionStates[confirm.id]).toBeUndefined() + expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined() + }) + + it('does not let an old rejected action clear feedback owned by a newer HITL projection', async () => { + const confirm = { ...card('replacement', 'action_required'), kind: 'confirm' as const } + store.upsertCard(confirm) + const old = deferred() + vi.spyOn(api, 'cardAction').mockReturnValue(old.promise) + const first = store.cardAction(confirm, 'confirm') + store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id })) + const projected = { ...confirm, body: [{ type: 'kv' as const, kv: [['来源', 'hitl:replacement']] as [string, string][] }] } + store.applyEvent(event(2, 'card.updated', { card: projected })) + const replacement = deferred>>() + vi.spyOn(api, 'resolveHITL').mockReturnValue(replacement.promise) + const second = store.cardAction(projected, 'confirm') + old.reject(new Error('400: rejected')) + await first + expect(store.getState().cardActionStates.replacement).toBe('submitting') + expect(store.getState().cardActionStates['hitl:replacement']).toBe('submitting') + vi.mocked(api.listCards).mockResolvedValue([{ ...projected, status: 'resolved' }]) + replacement.resolve({ resolved: true, pivoted: false, pivotText: '' }) + await second + expect(store.getState().cardActionStates.replacement).toBeUndefined() + }) + 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 }))