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:
+4
-4
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user