Why: The three-column workbench redesign converges entries and hierarchy: project switching tops the left rail (identity shown once), sessions and search stay centered, files/workspaces/account sink to the bottom. The right column becomes a Work/Files third column that absorbs the preview pane, which is no longer an independent fourth region; the artifacts workspace overlay folds into the Files entry, and stop control consolidates into the input area with a light fast/slow-lane status hint. Evidence: make test-frontend NOT_RUN at commit time; the run and its outcome are recorded in the Dev task card before integration. Impact: App.tsx layout and divider width tiers (work 280-560, files with open preview 420-1100); store UI state rightTab work/files plus filesView with syncFilesEntry replacing syncPreviewRail; Workbench dual entry; PreviewPane gains a back-to-list control; TaskSidebar project switcher; TopBar drops project identity; Conversation drops the header stop button and gains the input status line; workspaces.ts drops the artifacts builtin. Refs: three-column workbench proposal (2026-10-11), DESIGN.md 6/9.3 revision follows in the Dev repo.
277 lines
16 KiB
TypeScript
277 lines
16 KiB
TypeScript
// 左栏(找上下文):顶部切换项目,中间放会话与搜索,底部放文件、工作区和账户。
|
||
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
|
||
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 } from '../projectmeta'
|
||
import { buildWorkspaceEntries } from '../workspaces'
|
||
|
||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
|
||
const ref = useRef<HTMLDivElement>(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 = `${y}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 <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
|
||
}
|
||
|
||
function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) {
|
||
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>
|
||
{icon && <Icon name={icon} size={15} />}{children}
|
||
</button>
|
||
}
|
||
|
||
export function TaskSidebar() {
|
||
const projects = useStore(s => s.projects)
|
||
const tasks = useStore(s => s.tasks)
|
||
const me = useStore(s => s.me)
|
||
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)
|
||
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('')
|
||
|
||
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => {
|
||
const k = e.key.toLowerCase()
|
||
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
|
||
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
|
||
}
|
||
window.addEventListener('keydown', h, true)
|
||
return () => window.removeEventListener('keydown', h, true)
|
||
}, [])
|
||
|
||
const tools = buildWorkspaceEntries(serverWorkspaces)
|
||
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 = projectStage(currentProject?.settings)
|
||
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) => (
|
||
<span className="tsb-row-acts">
|
||
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||
</button>
|
||
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
||
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
||
<Icon name="shield" size={13} />
|
||
</button>
|
||
</span>
|
||
)
|
||
|
||
/** 切换项目:优先恢复该项目下正在看的会话,否则进第一个未归档会话。 */
|
||
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)
|
||
}
|
||
|
||
const taskRow = (t: Conversation) => (
|
||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||
onClick={() => store.openTask(t.id)}
|
||
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
|
||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||
{rowActions(t)}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
{/* 项目身份唯一落点:顶部切换项目(方案:项目导航置顶)。 */}
|
||
<div className="tsb-brand">
|
||
<button className="tsb-projrow" title="切换项目"
|
||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
||
<Icon name="folder" size={15} />
|
||
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
|
||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||
</button>
|
||
<span style={{ flex: 1 }} />
|
||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||
</div>
|
||
|
||
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
|
||
<div className="tsb-actions">
|
||
<div className="tsb-newtask">
|
||
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
|
||
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
|
||
</button>
|
||
<button className="tsb-newtask-caret" title="更多创建选项"
|
||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
|
||
</div>
|
||
<div className="tsb-search">
|
||
<Icon name="search" size={14} />
|
||
<input placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="tsb-orgrow">
|
||
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</span>
|
||
<span style={{ flex: 1 }} />
|
||
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
|
||
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
|
||
</div>
|
||
|
||
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||
<div className="tsb-scroll">
|
||
{creatingProject && (
|
||
<div className="tsb-newproject">
|
||
<input className="tsb-search" autoFocus value={newProjectName}
|
||
onChange={e => setNewProjectName(e.target.value)}
|
||
onKeyDown={async e => {
|
||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||
await store.createProject(newProjectName.trim())
|
||
setCreatingProject(false); setNewProjectName('')
|
||
}
|
||
if (e.key === 'Escape') setCreatingProject(false)
|
||
}}
|
||
placeholder="项目名称,回车创建" />
|
||
</div>
|
||
)}
|
||
|
||
{pinned.length > 0 && (
|
||
<>
|
||
<div className="tsb-seclabel">置顶</div>
|
||
{pinned.map(t => taskRow(t))}
|
||
</>
|
||
)}
|
||
|
||
{rest.length === 0 && pinned.length === 0 && (
|
||
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
|
||
)}
|
||
{rest.map(t => taskRow(t))}
|
||
</div>
|
||
|
||
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
||
<div className="tsb-bottom">
|
||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
|
||
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
||
<span className="tsb-tool-name">文件</span>
|
||
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
||
</button>
|
||
{tools.map(entry => (
|
||
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
|
||
<span className="tsb-tool-icon">{entry.icon}</span>
|
||
<span className="tsb-tool-name">{entry.title}</span>
|
||
<kbd>{entry.shortcut}</kbd>
|
||
</button>
|
||
))}
|
||
{serverWorkspaces.length === 0 && <div className="tsb-tool-note">领域工具由系统级插件提供</div>}
|
||
{currentProject && (
|
||
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
||
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
||
<span className="tsb-tool-name">项目设置</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
|
||
<div className="tsb-footer">
|
||
<button className="tsb-user" onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: el }) }}>
|
||
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
|
||
<span className="tsb-user-info">
|
||
<span className="tsb-user-name">{me?.name || me?.owner || '未连接'}</span>
|
||
<span className="tsb-user-sub">{me ? (me.auth ? (me.isAdmin ? '管理员 · 已认证' : '已认证') : '本地模式 · 默认租户') : '…'}</span>
|
||
</span>
|
||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||
</button>
|
||
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
|
||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||
</div>
|
||
|
||
{/* 弹出菜单们 */}
|
||
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||
{projects.map(p => {
|
||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||
const pstage = projectStage(p.settings)
|
||
return (
|
||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||
{p.name}
|
||
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
||
</MenuItem>
|
||
)
|
||
})}
|
||
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||
</Menu>
|
||
|
||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
|
||
))}
|
||
</Menu>
|
||
|
||
<Menu anchor={rowMenuAnchor} onClose={() => setMenu(null)}>
|
||
{rowTask && <>
|
||
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
||
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="settings" onClick={() => { if (rowTask) store.openProjectSettings(rowTask.projectId); setMenu(null) }}>项目设置</MenuItem>
|
||
</>}
|
||
</Menu>
|
||
|
||
<Menu anchor={menu?.kind === 'account' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||
<div className="tsb-acc-head">
|
||
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
|
||
<span>
|
||
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
||
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
||
</span>
|
||
</div>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
||
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
|
||
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
|
||
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
|
||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||
{me?.auth && <>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||
</>}
|
||
</Menu>
|
||
</>
|
||
)
|
||
}
|