diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 39efbd4..bfdcc2a 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -1,7 +1,6 @@ // 右栏(第三栏):「工作 / 文件」双入口。 // 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录); // 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。 -import { api } from '../api' import { store, useStore } from '../store' import type { Run } from '../contracts' import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui' @@ -14,15 +13,7 @@ import { PreviewPane } from './PreviewPane' // 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。 // 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。 export const docBridge: DocBridge = { - onOpenTaskSource: async (cardId,source)=>{ - const context=store.getState().currentConvId,project=store.getState().currentProjectId - try{const out=await api.taskSource(cardId,source.id) - if(context!==store.getState().currentConvId||project!==store.getState().currentProjectId)return - if(!out.artifact){store.toast('来源当前不可用,正在核实工作记录');if(context)void store.refreshCards(context);return} - store.requestTaskSourcePreview(out.artifact,{notice:out.versionState==='changed'?'当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论':'任务处理时引用的来源版本;下方是本项发现引用的片段',quote:out.source.quote??'',expectedHash:out.versionState==='current'?out.source.contentHash:undefined}) - if(out.versionState==='changed'&&context)void store.refreshCards(context) - }catch{if(context===store.getState().currentConvId&&project===store.getState().currentProjectId){store.toast('来源当前无法读取,正在核实工作记录');if(context)void store.refreshCards(context)}} - }, + onOpenTaskSource: (cardId, source) => store.openTaskSource(cardId, source), resolveDoc: id => store.getState().artifacts.find(a => a.id === id), onOpenDoc: id => { const art = store.getState().artifacts.find(a => a.id === id) diff --git a/src/store.ts b/src/store.ts index 6506b1a..e9142aa 100644 --- a/src/store.ts +++ b/src/store.ts @@ -20,6 +20,7 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice { } import type { ActivityCard, + TaskSourceRef, AgentProfile, Artifact, Conversation, @@ -218,7 +219,8 @@ function syncFilesEntry(hasTabs: boolean) { function setUI(patch: Partial) { const ui = { ...state.ui, ...patch } - state = { ...state, ui } + const navigation=(['rightTab','filesView','rightCollapsed','activeWorkspace'] as const).some(key=>key in patch&&patch[key]!==state.ui[key]) + state = { ...state, ui,previewOpenIntent:state.previewOpenIntent+(navigation?1:0) } try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ } applyTheme(ui.theme) applyFontSize(ui.fontSize) @@ -1255,6 +1257,36 @@ export const store = { * 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。 * 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。 */ + async openTaskSource(cardId: string, source: TaskSourceRef) { + const conversationId = state.currentConvId + const projectId = state.currentProjectId + const epoch = conversationEpoch + const intent = state.previewOpenIntent + 1 + set({ previewOpenIntent: intent }) + const isCurrent = () => conversationId === state.currentConvId && projectId === state.currentProjectId + && epoch === conversationEpoch && intent === state.previewOpenIntent + try { + const out = await api.taskSource(cardId, source.id) + if (!isCurrent()) return + if (!out.artifact) { + store.toast('来源当前不可用,正在核实工作记录') + if (conversationId) void store.refreshCards(conversationId) + return + } + store.requestTaskSourcePreview(out.artifact, { + notice: out.versionState === 'changed' + ? '当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论' + : '任务处理时引用的来源版本;下方是本项发现引用的片段', + quote: out.source.quote ?? '', + expectedHash: out.versionState === 'current' ? out.source.contentHash : undefined, + }) + if (out.versionState === 'changed' && conversationId) void store.refreshCards(conversationId) + } catch { + if (!isCurrent()) return + store.toast('来源当前无法读取,正在核实工作记录') + if (conversationId) void store.refreshCards(conversationId) + } + }, requestTaskSourcePreview(a:Artifact,sourceContext:{notice:string;quote:string;expectedHash?:string}){ store.requestPreview(a) const conv=previewTabsKey(state.currentConvId) @@ -1311,7 +1343,7 @@ export const store = { togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) }, activatePreview(id: string) { const conv = previewTabsKey(state.currentConvId) - set({ previewActive: { ...state.previewActive, [conv]: id } }) + set({ previewActive: { ...state.previewActive, [conv]: id },previewOpenIntent:state.previewOpenIntent+1 }) }, closePreview(id: string) { const conv = previewTabsKey(state.currentConvId) @@ -1319,6 +1351,7 @@ export const store = { set({ previewTabs: { ...state.previewTabs, [conv]: next.tabs }, previewActive: { ...state.previewActive, [conv]: next.activeId }, + previewOpenIntent:state.previewOpenIntent+1, }) if (next.tabs.length === 0) setUI({ previewExpanded: false }) syncFilesEntry(next.tabs.length > 0) diff --git a/test/work-state.test.ts b/test/work-state.test.ts index 1a5186a..2e7d539 100644 --- a/test/work-state.test.ts +++ b/test/work-state.test.ts @@ -443,3 +443,38 @@ it('rejects old revisions and foreign attempts in the task merge contract',()=>{ expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'a',revision:8}).revision).toBe(9) expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'b',revision:10}).attemptId).toBe('a') }) + +it('keeps the newer explicit preview when a source response arrives late',async()=>{ + const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'} + const pending=deferred>>() + vi.spyOn(api,'taskSource').mockReturnValue(pending.promise) + const {docBridge}=await import('../src/components/Workbench') + const opening=docBridge.onOpenTaskSource!('card',source) + store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0}) + pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'}) + await opening + expect(store.getState().previewActive.a).toBe('b') +}) + +it('rejects a source response after leaving and returning to the same conversation',async()=>{ + const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'} + const pending=deferred>>() + vi.spyOn(api,'taskSource').mockReturnValue(pending.promise) + const opening=store.openTaskSource('card',source) + await store.openConversation('b');await store.openConversation('a') + store.requestPreview({id:'new',name:'New',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0}) + pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'}) + await opening + expect(store.getState().previewActive.a).toBe('new') +}) +it('keeps the activated tab when a source request is still pending',async()=>{ + store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0}) + store.requestPreview({id:'c',name:'C',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0}) + const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'} + const pending=deferred>>() + vi.spyOn(api,'taskSource').mockReturnValue(pending.promise) + const opening=store.openTaskSource('card',source);store.activatePreview('b') + pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'}) + await opening + expect(store.getState().previewActive.a).toBe('b') +})