Files
Agent-System-Frontend/src/components/TaskSidebar.tsx
T
weichen 59ffe044b5 Consume the UI capability platform: shared icons/avatars, settings shell
Why: avatars, cards, and settings are base capabilities now (user
decision 2026-09-30); the host must eat its own dogfood so plugins get
the identical surfaces.

Evidence:
- Local icons.tsx removed; Icon/Avatar/BrandMark/primitives/Settings
  Shell imported from @ui; message stream gains avatar columns (user
  initial from /api/me name, assistant brand mark); SettingsModal
  rebuilt on SettingsShell (side-nav sections with badges, EmptyState
  for plugins, iconified actions); AdminUsersModal rows show shared
  avatars.
- tsconfig paths pin react/react-dom types to the frontend copy — the
  source-composed ui package otherwise resolves a second @types/react
  through its own node_modules during make frontend (npm ci), which
  breaks type identity (surfaced by SVGProps crossing the boundary);
  vite dedupe already guarantees one runtime copy.
- Headless on the real binary: avatar columns render, settings shell
  switches sections with badges, admin rows show avatars, zero console
  errors; build + vitest green.

Impact: visual/structural only; no API or state changes.

Refs: Agent-System-Documents proposal 2026-09-30-ui-redesign-and-auth.md
2026-09-30 14:22:37 +00:00

252 lines
14 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.
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
// 工作区快捷图标条保留(D25:⌘ 与左栏共享同一清单)。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark, workspaceIcon } from '@ui'
import type { Conversation } from '../contracts'
import { workspaceEntries } from './Workspaces'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
const GROUPS_KEY = 'as.taskGroups'
function loadCollapsed(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(GROUPS_KEY) ?? '{}') } catch { return {} }
}
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
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 currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const org = useStore(s => s.ui.taskOrg)
const showArchived = useStore(s => s.ui.showArchived)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed)
const [menu, setMenu] = useState<{ kind: 'new' | 'filter' | 'account' | `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 tglGroup = (key: string) => {
setCollapsed(c => {
const next = { ...c, [key]: !c[key] }
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(next)) } catch { /* ignore */ }
return next
})
}
const setOrg = (o: TaskOrg) => store.setUI({ taskOrg: o })
const visible = filterTasks(tasks, q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const groups = org === 'project' ? groupByProject(rest, projects) : groupByTimeline(rest)
const currentProject = projects.find(p => p.id === currentProjectId)
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>
</span>
)
const taskRow = (t: Conversation, badge?: string) => (
<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>
{badge && <span className="tsb-pbadge">{badge}</span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
{rowActions(t)}
</div>
)
return (
<>
<div className="tsb-brand">
<BrandMark size={24} />
<span className="tsb-name">Agent-System</span>
<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>
{/* 组织视图切换(P8)+ 过滤 */}
<div className="tsb-orgrow">
<div className="tsb-pills">
<button className={`tsb-pill ${org === 'project' ? 'on' : ''}`} onClick={() => setOrg('project')}>按项目</button>
<button className={`tsb-pill ${org === 'timeline' ? 'on' : ''}`} onClick={() => setOrg('timeline')}>时间线</button>
</div>
<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, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</>
)}
{groups.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">尚无任务——点击「新建任务」开始<br />(项目内素材上传、检索与生成全程可在对话中完成)</div>
)}
{groups.map(g => (
<div key={g.key} className={`tsb-group ${collapsed[g.key] ? 'closed' : ''}`}>
{org === 'project' ? (
<div className="tsb-group-head" onClick={() => tglGroup(g.key)}>
<span className="tsb-chev"><Icon name="chevron-right" size={13} /></span>
<span className="tsb-group-name" title={g.label}>{g.label}</span>
<span className="tsb-group-cnt">{g.tasks.length}</span>
<span style={{ flex: 1 }} />
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
</div>
) : (
<div className="tsb-seclabel">{g.label}</div>
)}
{!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</div>
))}
</div>
{/* 工作区快捷图标条(D25:与 ⌘1–9 同一清单) */}
<div className="tsb-wsstrip">
{workspaceEntries().map(e => (
<button key={e.key} title={`${e.title}(${e.shortcut})`} onClick={() => store.openWorkspace(e.key)}><Icon name={workspaceIcon(e.icon)} size={15} /></button>
))}
<span style={{ flex: 1 }} />
<button title={theme === 'dark' ? '切换浅色' : '切换深色'} onClick={() => store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' })}><Icon name={theme === 'dark' ? 'sun' : 'moon'} size={15} /></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.openInbox()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
</div>
{/* 弹出菜单们 */}
<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="clock" onClick={() => { store.setUI({ taskOrg: 'timeline' }); 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.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
{me?.isAdmin && <MenuItem icon="users" onClick={() => { store.setUI({ adminUsersOpen: true }); setMenu(null) }}>用户管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
</>}
</Menu>
</>
)
}