Open previews from the empty state and restore the last conversation on reload

Why: reproduced with headless Chromium - clicking an artifact row with no
conversation selected closed the workspace overlay and nothing else appeared.
requestPreview stores tabs under currentConvId ?? '_' while the render side
required a non-null currentConvId, so the preview pane never mounted in the
empty state. The empty state was itself a trap: currentConvId was not
persisted, so every reload landed there.
Evidence: vitest 85/85 (new preview-empty-state tests for the key contract
and orphan-tab merge); browser replay on the fixed build shows the preview
pane mounting with rendered docx content from the empty state and the
conversation restoring after reload (screenshots in the Dev task card).
Impact: previewTabsKey() unifies the read/write key; entering a conversation
adopts orphan '_' tabs; the last conversation id persists and restores once
on boot; deliverable cards toast when the artifact is gone instead of
silently doing nothing.
Refs: Dev task feature/empty-state-preview-fix; diagnosis session 2026-10-10.
This commit is contained in:
2026-10-10 08:02:21 +00:00
parent 84b73ab328
commit 475fd3a8d6
6 changed files with 99 additions and 13 deletions
+4 -4
View File
@@ -9,6 +9,7 @@ import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
import { PreviewPane } from './components/PreviewPane'
import { previewTabsKey } from './previewtabs'
import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
@@ -30,10 +31,9 @@ export function App() {
}, [])
const ui = useStore(s => s.ui)
const previewOpen = useStore(s => {
const id = s.currentConvId
return id ? (s.previewTabs[id]?.length ?? 0) > 0 : false
})
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
// 工件也要渲染预览区,否则点击只剩工件弹层关闭、毫无反馈。
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const previewExpanded = previewOpen && ui.previewExpanded
+5 -1
View File
@@ -124,7 +124,11 @@ function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: st
const name = artifact.name || art?.name || '文件'
const icon = artifactIcon(name, artifact.kind ?? art?.kind)
const size = humanSize(art?.size)
const open = () => { if (art) store.requestPreview(art) }
const open = () => {
if (art) { store.requestPreview(art); return }
// 工件已不在当前项目(被删/跨项目残留):给一句话反馈,不再无声无息。
store.toast(`「${name}」已不在当前项目中,无法预览`)
}
return (
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
+4 -3
View File
@@ -5,12 +5,13 @@ import { Btn, FilePreview, Icon, TabStrip } from '@ui'
import { api } from '../api'
import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
import { previewTabsKey } from '../previewtabs'
import { store, useStore } from '../store'
export function PreviewPane() {
const convId = useStore(s => s.currentConvId)
const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? [])
const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? null)
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
const tabs = useStore(s => s.previewTabs[previewTabsKey(s.currentConvId)] ?? [])
const activeId = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null)
const artifacts = useStore(s => s.artifacts)
const expanded = useStore(s => s.ui.previewExpanded)
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
+20
View File
@@ -25,3 +25,23 @@ export function closePreviewTab(
const neighbor = next[Math.min(i, next.length - 1)] ?? null
return { tabs: next, activeId: neighbor?.id ?? null }
}
/** 预览标签的会话键。空态(未选会话)统一落在 '_':写入(requestPreview)与
* 渲染(previewOpen/PreviewPane)共用同一口径,空态点开工件才能出现预览区。 */
export function previewTabsKey(currentConvId: string | null | undefined): string {
return currentConvId ?? '_'
}
/** 进入会话时把空态打开的孤儿标签('_' 键)并入会话键下;按 id 去重,
* 会话侧顺序在前,'_' 键清空。无孤儿或目标即 '_' 时原样返回。 */
export function mergeOrphanPreviewTabs(
tabsByConv: Record<string, PreviewTab[]>,
convId: string,
): Record<string, PreviewTab[]> {
const orphan = tabsByConv['_'] ?? []
if (orphan.length === 0 || convId === '_') return tabsByConv
const own = tabsByConv[convId] ?? []
const ids = new Set(own.map(t => t.id))
const merged = [...own, ...orphan.filter(t => !ids.has(t.id))]
return { ...tabsByConv, [convId]: merged, '_': [] }
}
+36 -5
View File
@@ -3,7 +3,7 @@
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
@@ -184,6 +184,17 @@ function setUI(patch: Partial<UIState>) {
listeners.forEach(l => l())
}
// 会话恢复(空态点工件无反应的高频前置):currentConvId 不持久化时刷新必回空态,
// 空态下 requestPreview 的标签挂在 '_' 键、渲染判空键——点击只剩弹层关闭,没有任何反馈。
const CONV_KEY = 'as.conv'
let convRestoreDone = false
function persistConvId(id: string | null) {
try { id ? localStorage.setItem(CONV_KEY, id) : localStorage.removeItem(CONV_KEY) } catch { /* ignore */ }
}
function savedConvId(): string | null {
try { return localStorage.getItem(CONV_KEY) } catch { return null }
}
let systemQuery: MediaQueryList | null = null
let systemHandler: (() => void) | null = null
@@ -252,7 +263,18 @@ export const store = {
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
try {
const tasks = await api.listTasks(includeArchived)
set({ tasks })
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
if (!convRestoreDone) {
convRestoreDone = true
const saved = savedConvId()
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
store.openConversation(saved)
}
}
} catch { /* 列表竞态,静默 */ }
},
async refreshMe() {
@@ -336,6 +358,15 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
if ((state.previewTabs['_'] ?? []).length > 0) {
const active = state.previewActive[convId] ?? state.previewActive['_'] ?? null
set({
previewTabs: mergeOrphanPreviewTabs(state.previewTabs, convId),
previewActive: { ...state.previewActive, [convId]: active },
})
}
syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
@@ -804,7 +835,7 @@ export const store = {
* 打开第一张标签时收起右栏,关掉最后一张时恢复,并收回展开态。
*/
requestPreview(a: Artifact) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
@@ -814,11 +845,11 @@ export const store = {
},
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
set({ previewActive: { ...state.previewActive, [conv]: id } })
},
closePreview(id: string) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
set({
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
+30
View File
@@ -0,0 +1,30 @@
// 空态点工件无反应(2026-10-10 实测复现):requestPreview 写 '_' 键、渲染判
// currentConvId 非空——键口径不一致导致空态点击只剩弹层关闭。这里测纯函数口径。
import { describe, expect, it } from 'vitest'
import { mergeOrphanPreviewTabs, previewTabsKey } from '../src/previewtabs'
describe('previewTabsKey', () => {
it('falls back to the empty-state key when no conversation is selected', () => {
expect(previewTabsKey(null)).toBe('_')
expect(previewTabsKey(undefined)).toBe('_')
expect(previewTabsKey('cnv_1')).toBe('cnv_1')
})
})
describe('mergeOrphanPreviewTabs', () => {
it('merges empty-state tabs into the conversation key and clears the orphan key', () => {
const got = mergeOrphanPreviewTabs({
_: [{ id: 'a', name: 'A.docx' }, { id: 'b', name: 'B.md' }],
cnv_1: [{ id: 'b', name: 'B.md' }],
}, 'cnv_1')
expect(got._).toEqual([])
expect(got.cnv_1.map(t => t.id)).toEqual(['b', 'a'])
})
it('returns the input untouched when there is no orphan or the target is the orphan key', () => {
const none = { cnv_1: [{ id: 'a', name: 'A' }] }
expect(mergeOrphanPreviewTabs(none, 'cnv_1')).toBe(none)
const self = { _: [{ id: 'a', name: 'A' }] }
expect(mergeOrphanPreviewTabs(self, '_')).toBe(self)
})
})