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.
188 lines
8.1 KiB
TypeScript
188 lines
8.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { auth } from './auth'
|
||
import { api } from './api'
|
||
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
|
||
import { ChangePasswordModal } from './components/AdminUsersModal'
|
||
import { AdminZone } from './components/AdminZone'
|
||
import { Icon } from '@ui'
|
||
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'
|
||
import { SettingsModal } from './components/SettingsModal'
|
||
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||
import { TopBar } from './components/TopBar'
|
||
import { workspaceEntries } from './components/Workspaces'
|
||
|
||
// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。
|
||
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||
|
||
export function App() {
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
|
||
window.addEventListener('keydown', h, true)
|
||
return () => window.removeEventListener('keydown', h, true)
|
||
}, [])
|
||
|
||
const ui = useStore(s => s.ui)
|
||
// 预览键与 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
|
||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||
const [mustChangeOwner, setMustChangeOwner] = useState('')
|
||
|
||
useEffect(() => {
|
||
;(async () => {
|
||
try {
|
||
const r = await auth.fetch('/api/health')
|
||
if (r.status === 401) { setAuthState('login'); return }
|
||
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
||
const me = await api.me().catch(() => null)
|
||
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
|
||
setAuthState('ok')
|
||
} catch {
|
||
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
||
}
|
||
})()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (authState !== 'ok') return
|
||
store.refreshProjects()
|
||
store.refreshProfiles()
|
||
store.refreshPlugins()
|
||
}, [authState])
|
||
|
||
const drag = useRef<{ side: 'left' | 'right' | 'preview'; startX: number; startW: number; overshoot: boolean } | null>(null)
|
||
|
||
const onDividerDown = (side: 'left' | 'right' | 'preview') => (e: React.MouseEvent) => {
|
||
e.preventDefault()
|
||
const startW = side === 'left' ? ui.leftWidth : side === 'preview' ? ui.previewWidth : ui.rightWidth
|
||
drag.current = { side, startX: e.clientX, startW, overshoot: false }
|
||
const move = (ev: MouseEvent) => {
|
||
const d = drag.current
|
||
if (!d) return
|
||
if (d.side === 'left') {
|
||
const w = d.startW + (ev.clientX - d.startX)
|
||
if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
||
d.overshoot = false
|
||
store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) })
|
||
} else if (d.side === 'preview') {
|
||
const w = d.startW - (ev.clientX - d.startX)
|
||
if (w < PREVIEW_MIN) return
|
||
store.setUI({ previewWidth: Math.min(PREVIEW_MAX, w) })
|
||
} else {
|
||
const w = d.startW - (ev.clientX - d.startX)
|
||
if (w < RIGHT_MIN) return
|
||
store.setUI({ rightCollapsed: false, rightWidth: Math.min(RIGHT_MAX, w) })
|
||
}
|
||
}
|
||
const up = () => {
|
||
const d = drag.current
|
||
if (d?.side === 'left' && d.overshoot) store.setUI({ leftCollapsed: true })
|
||
drag.current = null
|
||
window.removeEventListener('mousemove', move)
|
||
window.removeEventListener('mouseup', up)
|
||
}
|
||
window.addEventListener('mousemove', move)
|
||
window.addEventListener('mouseup', up)
|
||
}
|
||
|
||
if (authState === 'login') {
|
||
return <LoginPage
|
||
onAuthed={() => setAuthState('ok')}
|
||
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
|
||
}
|
||
if (authState === 'mustchange') {
|
||
return <ChangePasswordGate
|
||
owner={mustChangeOwner}
|
||
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
|
||
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
|
||
}
|
||
if (authState !== 'ok') {
|
||
return <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
|
||
}
|
||
|
||
const tools = workspaceEntries()
|
||
return (
|
||
<div className="app">
|
||
<TopBar />
|
||
<div className="app-body">
|
||
{!ui.leftCollapsed ? (
|
||
<>
|
||
<div className="left-nav" style={{ width: ui.leftWidth }}>
|
||
<TaskSidebar />
|
||
</div>
|
||
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
|
||
</>
|
||
) : (
|
||
<div className="left-nav collapsed" style={{ width: 52 }}>
|
||
<div className="icon-only">
|
||
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
|
||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||
</button>
|
||
{tools.map(entry => (
|
||
<button key={entry.key} title={`${entry.title}(${entry.shortcut})`} onClick={() => store.openWorkspace(entry.key)}>{entry.icon}</button>
|
||
))}
|
||
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="main" hidden={previewExpanded}>
|
||
<Conversation />
|
||
</div>
|
||
|
||
{previewOpen && (
|
||
<>
|
||
{!previewExpanded && (
|
||
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
|
||
)}
|
||
<div className={`preview-slot mk-panel-grow${previewExpanded ? ' expanded' : ''}`} style={previewExpanded ? undefined : { width: ui.previewWidth }}>
|
||
<PreviewPane />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{!ui.rightCollapsed ? (
|
||
<>
|
||
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
||
<div className="right-panel" style={{ width: ui.rightWidth }}>
|
||
<Workbench />
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||
<div className="icon-only">
|
||
<button title="展开收件箱" onClick={store.toggleRight}>◧
|
||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Workspaces />
|
||
{trackerRunId && <AgentTracker />}
|
||
{ui.settingsOpen && <SettingsModal />}
|
||
{ui.projectSettingsId && <ProjectSettingsModal />}
|
||
{ui.adminZoneOpen && <AdminZone />}
|
||
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||
{toast && <div className="toast">{toast}</div>}
|
||
</div>
|
||
)
|
||
}
|