Files
Agent-System-Frontend/src/App.tsx
T
weichen 55a64f40e8 Respect canonical project bindings and isolate account state
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
2026-10-13 08:20:44 +00:00

247 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}