Why: Project members need RDM commands to address the registered domain project. Failed project states and responses from a previous account must not create work, report a rejected mutation as successful, or restore private content. Evidence: SELF npm test: 32 files and 251 tests passed, including project command, mutation rejection, archived history, and delayed account response coverage. SELF npm run build and git diff --check passed. Original regressions were observed before implementation; root owns composition and independent replay. Impact: Keep host IDs for conversation and base settings APIs; send the decorated domain ID to RDM project commands. Reuse existing sidebar, settings and toast states. Reset identity projections and invalidate old asynchronous responses. No deployment, database change, push, merge, or workspace cleanup. Refs: Dev feature-project-member-access task card and implementation plan Task 4
247 lines
13 KiB
TypeScript
247 lines
13 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 { 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'
|
||
import { selectWorkbenchLayout } from './workbenchLayout'
|
||
import './companion-shell.css'
|
||
|
||
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
||
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
||
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() {
|
||
const ui = useStore(s => s.ui)
|
||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||
const previewOpenIntent = useStore(s => s.previewOpenIntent)
|
||
const rightTab = useStore(s => s.ui.rightTab)
|
||
const toast = useStore(s => s.toast)
|
||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
||
const filesWide = rightTab === 'files' && ui.filesView === 'preview' && previewOpen
|
||
const [viewportWidth, setViewportWidth] = useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth)
|
||
const [contextOpen, setContextOpen] = useState(false)
|
||
const layout = selectWorkbenchLayout({ ...ui, width: viewportWidth, contextOpen, previewOpen })
|
||
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(() => {
|
||
const onResize = () => setViewportWidth(window.innerWidth)
|
||
window.addEventListener('resize', onResize)
|
||
return () => window.removeEventListener('resize', onResize)
|
||
}, [])
|
||
|
||
// Explicit openings leave mobile context even if pane values match; metadata restores keep the reading choice.
|
||
useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, ui.filesView, ui.previewExpanded, previewOpenIntent])
|
||
useEffect(() => { if (!layout.compact) setContextOpen(false) }, [layout.compact])
|
||
|
||
const returnToConversation = () => {
|
||
setContextOpen(false)
|
||
store.setUI(layout.compact ? { rightCollapsed: true, previewExpanded: false } : { previewExpanded: false })
|
||
}
|
||
const openContext = () => {
|
||
if (!layout.compact) store.toggleLeft()
|
||
else if (contextOpen) returnToConversation()
|
||
else setContextOpen(true)
|
||
}
|
||
const openWork = () => {
|
||
setContextOpen(false)
|
||
store.openInbox()
|
||
store.setUI({ inboxFilter: 'all', previewExpanded: false })
|
||
}
|
||
const openPending = () => { setContextOpen(false); store.focusPending() }
|
||
const openFiles = () => { setContextOpen(false); store.openFiles() }
|
||
const conversationOpened = () => { if (layout.compact) returnToConversation() }
|
||
|
||
// Keep conversation/context shortcuts available even when their pane is hidden.
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => {
|
||
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
|
||
const key = e.key.toLowerCase()
|
||
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return }
|
||
if (key === 'b') { e.preventDefault(); openContext(); return }
|
||
}
|
||
workspaceShortcuts(e)
|
||
}
|
||
window.addEventListener('keydown', h, true)
|
||
return () => window.removeEventListener('keydown', h, true)
|
||
}, [authState, layout.compact, contextOpen])
|
||
|
||
useEffect(() => {
|
||
;(async () => {
|
||
try {
|
||
const r = await auth.fetch('/api/health')
|
||
if (r.status === 401) { setAuthState('login'); return }
|
||
// 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定
|
||
//(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立
|
||
// set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的
|
||
// 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必
|
||
// 401),key 持有者清 key 回登录页,改走密码登录完成改密。
|
||
const me = await api.me().catch(() => null)
|
||
const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword
|
||
if (gated) {
|
||
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
||
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
||
}
|
||
setAuthState('ok')
|
||
} catch {
|
||
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
||
}
|
||
})()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (authState !== 'ok') return
|
||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||
let active = true
|
||
store.refreshMe().then(() => {
|
||
if (!active) return
|
||
void store.refreshProjects()
|
||
void store.refreshProfiles()
|
||
void store.refreshPlugins()
|
||
})
|
||
return () => { active = false }
|
||
}, [authState])
|
||
|
||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||
|
||
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||
e.preventDefault()
|
||
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
||
const wide = side === 'right' && filesWide
|
||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||
drag.current = {
|
||
side, startX: e.clientX, startW: side === 'left' ? layout.leftWidth : layout.rightWidth, overshoot: false,
|
||
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
||
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
||
key,
|
||
}
|
||
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 < d.min - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
||
d.overshoot = false
|
||
store.setUI({ leftCollapsed: false, leftWidth: Math.min(d.max, Math.max(d.min, w)) })
|
||
} else {
|
||
const w = d.startW - (ev.clientX - d.startX)
|
||
if (w < d.min) return
|
||
store.setUI({ rightCollapsed: false, [d.key]: Math.min(d.max, w) } as Partial<typeof ui>)
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
const onDividerKey = (side: 'left' | 'right') => (e: React.KeyboardEvent) => {
|
||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||
e.preventDefault()
|
||
const wide = side === 'right' && filesWide
|
||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||
const min = side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN
|
||
const max = side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX
|
||
const current = side === 'left' ? layout.leftWidth : layout.rightWidth
|
||
const delta = (e.key === 'ArrowRight' ? 16 : -16) * (side === 'left' ? 1 : -1)
|
||
store.setUI({ [key]: Math.min(max, Math.max(min, current + delta)) })
|
||
}
|
||
|
||
if (authState === 'login') {
|
||
return <LoginPage
|
||
onAuthed={() => setAuthState('ok')}
|
||
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
|
||
}
|
||
if (authState === 'mustchange') {
|
||
// 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true:
|
||
// 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。
|
||
return <ChangePasswordGate
|
||
owner={mustChangeOwner}
|
||
onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }}
|
||
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 companion-app${layout.compact ? ' compact' : ''}`}>
|
||
<TopBar compact={layout.compact} activePane={layout.pane} showReturn={layout.previewExpanded}
|
||
onContext={openContext} onConversation={returnToConversation} onWork={openWork} onFiles={openFiles} />
|
||
<div className="app-body" data-active-pane={layout.pane}>
|
||
<aside className={`left-nav${layout.contextCollapsed ? ' collapsed' : ''}`} aria-label="上下文"
|
||
hidden={!layout.showContext} style={{ width: layout.leftWidth }}>
|
||
<div className="shell-context-content" hidden={layout.contextCollapsed}>
|
||
<TaskSidebar onCollapse={layout.compact ? returnToConversation : store.toggleLeft}
|
||
onConversationOpen={conversationOpened} onFilesOpen={openFiles} onWorkOpen={openPending} />
|
||
</div>
|
||
<div className="icon-only" hidden={!layout.contextCollapsed}>
|
||
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||
<button title="新建对话(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||
<button title="工作" onClick={openWork}><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>
|
||
</aside>
|
||
{layout.leftDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整上下文宽度" aria-orientation="vertical"
|
||
aria-valuemin={LEFT_MIN} aria-valuemax={LEFT_MAX} aria-valuenow={Math.round(layout.leftWidth)}
|
||
onKeyDown={onDividerKey('left')} onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft}
|
||
title="拖动或用方向键调整宽度;双击收起" />}
|
||
|
||
<main className="main" hidden={!layout.showConversation} aria-label="对话">
|
||
<Conversation />
|
||
</main>
|
||
|
||
{layout.rightDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整工作或文件宽度" aria-orientation="vertical"
|
||
aria-valuemin={filesWide ? PREVIEW_MIN : RIGHT_MIN} aria-valuemax={filesWide ? PREVIEW_MAX : RIGHT_MAX}
|
||
aria-valuenow={Math.round(layout.rightWidth)} onKeyDown={onDividerKey('right')}
|
||
onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动或用方向键调整宽度;双击收起" />}
|
||
<aside className={`right-panel mk-panel-grow${layout.previewExpanded ? ' expanded' : ''}`}
|
||
aria-label={rightTab === 'files' ? '文件' : '工作'} hidden={!layout.showRight} style={{ width: layout.rightWidth }}>
|
||
<Workbench />
|
||
</aside>
|
||
</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>
|
||
)
|
||
}
|