// 左栏(找上下文):顶部切换项目,中间放会话与搜索,底部放文件、工作区和账户。 // 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。 import { useEffect, useRef, useState } from 'react' import { store, useStore } from '../store' import { auth } from '../auth' import { api } from '../api' import { Icon } from '@ui' import type { Conversation } from '../contracts' import { relativeTime, filterTasks } from '../tasklist' import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta' import { buildWorkspaceEntries } from '../workspaces' /** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */ function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) { const ref = useRef(null) useEffect(() => { if (!anchor || !ref.current) return const r = anchor.getBoundingClientRect() const el = ref.current let x = r.left, y = r.bottom + 6 if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8 if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6 el.style.left = `${Math.max(8, x)}px` el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - el.offsetHeight - 8))}px` const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() } window.addEventListener('mousedown', h) return () => window.removeEventListener('mousedown', h) }, [anchor]) if (!anchor) return null return
e.stopPropagation()}>{children}
} function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters[0]['name']; children: React.ReactNode }) { return } interface TaskSidebarProps { onCollapse?: () => void onConversationOpen?: () => void onFilesOpen?: () => void onWorkOpen?: () => void } export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, onFilesOpen = store.openFiles, onWorkOpen = store.focusPending, }: TaskSidebarProps = {}) { const projects = useStore(s => s.projects) const tasks = useStore(s => s.tasks) const me = useStore(s => s.me) const adminSections = useStore(s => s.adminSections) const artifacts = useStore(s => s.artifacts) const currentConvId = useStore(s => s.currentConvId) const currentProjectId = useStore(s => s.currentProjectId) const showArchived = useStore(s => s.ui.showArchived) const serverWorkspaces = useStore(s => s.serverWorkspaces) /** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */ const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable) const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable) const theme = useStore(s => s.ui.theme) const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length + s.projectCards.filter(c => c.status === 'action_required').length) const [q, setQ] = useState('') const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null) const [creatingProject, setCreatingProject] = useState(false) const [newProjectName, setNewProjectName] = useState('') const createConversation = async (projectId?: string) => { try { await store.createTask(projectId) onConversationOpen?.() } catch { /* store owns the actionable failure toast */ } } const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable }) const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q) const pinned = visible.filter(t => t.pinned) const rest = visible.filter(t => !t.pinned) const currentProject = projects.find(p => p.id === currentProjectId) const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings) const workError = currentProject ? projectWorkError(currentProject) : null const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null const rowActions = (t: Conversation) => ( ) /** 切换项目:优先恢复该项目下正在看的会话,否则进第一个未归档会话。 */ const switchProject = (pid: string) => { const next = tasks.find(t => t.projectId === pid && t.id === currentConvId) ?? tasks.find(t => t.projectId === pid && !t.archived) if (next) store.openTask(next.id) else store.openProject(pid) onConversationOpen?.() } const taskRow = (t: Conversation) => (
{ e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}> {rowActions(t)}
) return ( <> {/* 项目身份唯一落点:顶部切换项目(方案:项目导航置顶)。 */}
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
setQ(e.target.value)} />
本项目会话
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
{creatingProject && (
setNewProjectName(e.target.value)} onKeyDown={async e => { if (e.key === 'Enter' && newProjectName.trim()) { try { await store.createProject(newProjectName.trim()) setCreatingProject(false); setNewProjectName('') } catch { /* keep the input available for a corrected name */ } } if (e.key === 'Escape') setCreatingProject(false) }} placeholder="项目名称,回车创建" />
)} {pinned.length > 0 && ( <>
置顶
{pinned.map(t => taskRow(t))} )} {rest.length === 0 && pinned.length === 0 && (
{workError ?? '从「新建对话」开始,描述你想完成的工作。'}
)} {rest.map(t => taskRow(t))}
{/* 底部(方案:文件、工作区、账户沉底)。 */}
{tools.map(entry => ( ))} {currentProject && ( )}
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
{pendingBadge > 0 && }
{/* 弹出菜单们 */} setMenu(null)}> {projects.map(p => { const count = tasks.filter(t => t.projectId === p.id && !t.archived).length const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings) return ( { switchProject(p.id); setMenu(null) }}> {p.name} {p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话 ) })} {projects.length === 0 &&
还没有项目
}
{ setCreatingProject(true); setMenu(null) }}>新建项目…
setMenu(null)}> { createConversation(); setMenu(null) }}>新建对话 · 当前项目⌘N
{ setCreatingProject(true); setMenu(null) }}>新建项目… {projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => ( { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话 ))}
setMenu(null)}> {rowTask && <> { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'} { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}
{ store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'} { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}
{ if (rowTask) store.openProjectSettings(rowTask.projectId); setMenu(null) }}>项目设置 }
setMenu(null)}>
{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}
{me?.name || '未连接'}
{me?.owner ? `租户:${me.owner}` : '—'}
{ store.openSettings(true); setMenu(null) }}>偏好设置 { onWorkOpen(); setMenu(null) }}>待处理事项{pendingBadge > 0 ? `(${pendingBadge})` : ''} { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码… { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''} { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''} { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''} { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''} {(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…} {me?.auth && <>
{ api.logout().catch(() => undefined); auth.logout() }}>退出登录 }
) }