Open previews from the empty state and restore the last conversation on reload #49
+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