Rebuild the left nav as a task-centric sidebar (option A, R1)
Why: creation entries were split (project + at sidebar top-left, new session at bottom-left) and the project tree forced per-project navigation; the approved option A design makes one primary New Task entry, organization a view mode, and identity always present. Evidence: - New TaskSidebar: single New Task split button (current-project context, Cmd-N, secondary menu for new project / new task in project), project <-> timeline organization with persisted collapse, pinned section, archive view toggle, hover row actions, account footer fed by /api/me (embedded mode shows local tenant; logout clears the key), workspace icon strip retained (D25 shared list with Cmd-1..9). - store: tasks = cross-project list via GET /api/conversations; openTask switches project context; pin/archive optimistic + refresh; last project context persisted; stale conversations field removed. - tasklist.ts pure helpers + 6 vitest cases; npm run build passes; headless walk on the real binary: cross-project open, pinned-first, archive hide/show/restore, new-task flow, zero console errors. Impact: ProjectNav removed; contracts.ts regenerated from core (pinned/archived); no composer/right-pane changes (D25/D26/D31 intact). Refs: Agent-System-Documents docs/proposals/2026-09-30-ui-redesign-and-auth.md
This commit is contained in:
+7
-4
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { auth } from './auth'
|
||||
import { store, useStore } from './store'
|
||||
import { ProjectNav } from './components/ProjectNav'
|
||||
import { TaskSidebar } from './components/TaskSidebar'
|
||||
import { Conversation } from './components/Conversation'
|
||||
import { Workbench } from './components/Workbench'
|
||||
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
||||
@@ -113,15 +113,18 @@ export function App() {
|
||||
{!ui.leftCollapsed ? (
|
||||
<>
|
||||
<div className="left-nav" style={{ width: ui.leftWidth }}>
|
||||
<ProjectNav />
|
||||
<TaskSidebar />
|
||||
</div>
|
||||
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
|
||||
</>
|
||||
) : (
|
||||
<div className="left-nav collapsed" style={{ width: 52 }}>
|
||||
<div className="icon-only">
|
||||
<button title="展开侧栏" onClick={store.toggleLeft}>☰</button>
|
||||
<button title="新会话" onClick={() => store.createConversation()}>✚</button>
|
||||
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}>☰</button>
|
||||
<button title="新建任务(⌘N)" onClick={() => store.createTask()}>✚</button>
|
||||
<button title="收件箱" onClick={() => store.openInbox()}>◍
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
<button title="设置" onClick={() => store.openSettings(true)}>⚙</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,10 @@ export const api = {
|
||||
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
|
||||
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
|
||||
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
|
||||
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean }>(r)),
|
||||
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
||||
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
||||
|
||||
@@ -30,6 +30,11 @@ export const auth = {
|
||||
headers.set('Authorization', `Bearer ${key}`)
|
||||
return fetch(input, { ...init, headers })
|
||||
},
|
||||
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
|
||||
logout() {
|
||||
this.clear()
|
||||
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
|
||||
},
|
||||
// 无法带头的资源 URL(EventSource / <img> / 下载):追加 ?key=。
|
||||
withKey(url: string): string {
|
||||
const key = this.apiKey()
|
||||
|
||||
@@ -213,7 +213,7 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) =>
|
||||
|
||||
export function Conversation() {
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const conversation = useStore(s => s.conversations.find(c => c.id === s.currentConvId))
|
||||
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
|
||||
const messages = useStore(s => s.messages)
|
||||
const activeHitl = useStore(s => s.activeHitl)
|
||||
const live = useStore(s => s.live)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { workspaceEntries } from './Workspaces'
|
||||
|
||||
export function ProjectNav() {
|
||||
const projects = useStore(s => s.projects)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const conversations = useStore(s => s.conversations)
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({})
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
|
||||
useEffect(() => { setNewName(`项目 ${projects.length + 1}`) }, [projects.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="nav-header">
|
||||
<span className="nav-label">Agent-System</span>
|
||||
<button title="新建项目" onClick={() => setCreating(v => !v)}>✚</button>
|
||||
</div>
|
||||
<div className="ws-quick">
|
||||
<div className="nav-label">工作区</div>
|
||||
<div className="ws-quick-list">
|
||||
{workspaceEntries().map(e => (
|
||||
<button key={e.key} className="ws-quick-item" title={`${e.title}(${e.shortcut})`}
|
||||
onClick={() => store.openWorkspace(e.key)}>
|
||||
<span>{e.icon}</span><span className="name">{e.title}</span><span className="sc">{e.shortcut}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{creating && (
|
||||
<div style={{ padding: '0 10px 8px', display: 'flex', gap: 6 }}>
|
||||
<input className="search-input" value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={async e => {
|
||||
if (e.key === 'Enter' && newName.trim()) { await store.createProject(newName.trim()); setCreating(false) }
|
||||
}} placeholder="项目名称" autoFocus />
|
||||
<button className="btn" onClick={async () => { if (newName.trim()) { await store.createProject(newName.trim()); setCreating(false) } }}>建</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="nav-scroll">
|
||||
{projects.length === 0 && <div style={{ padding: 12, color: 'var(--text-faint)', fontSize: 12.5 }}>尚无项目——点击 ✚ 创建</div>}
|
||||
{projects.map(p => {
|
||||
const open = !collapsedProjects[p.id]
|
||||
return (
|
||||
<div className="nav-project" key={p.id}>
|
||||
<div className={`nav-project-name ${open ? 'open' : ''} ${p.id === currentProjectId ? '' : ''}`}
|
||||
style={p.id === currentProjectId ? { color: 'var(--text)' } : undefined}
|
||||
onClick={async () => {
|
||||
setCollapsedProjects(c => ({ ...c, [p.id]: !(c[p.id] ?? p.id === currentProjectId ? false : true) }))
|
||||
await store.openProject(p.id)
|
||||
}}>
|
||||
<span className="chev">▶</span>
|
||||
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
|
||||
</div>
|
||||
{open && p.id === currentProjectId && conversations.map(c => (
|
||||
<div key={c.id} className={`nav-conv ${c.id === currentConvId ? 'active' : ''}`}
|
||||
onClick={() => store.openConversation(c.id)} title={c.title}>
|
||||
{c.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="nav-footer">
|
||||
<button className="nav-label" onClick={() => store.createConversation()}>+ 新会话</button>
|
||||
<button className="nav-label" onClick={() => store.openSettings(true)}>⚙ 设置</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// 左栏(方案 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 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, children }: { onClick: () => void; danger?: boolean; children: React.ReactNode }) {
|
||||
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>{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) }}>📌</button>
|
||||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||||
{t.archived ? '↩' : '🗄'}
|
||||
</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">
|
||||
<span className="tsb-logo">A</span>
|
||||
<span className="tsb-name">Agent-System</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}>⇤</button>
|
||||
</div>
|
||||
|
||||
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
|
||||
<div className="tsb-actions">
|
||||
<div className="tsb-newtask">
|
||||
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
|
||||
+ 新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
|
||||
</button>
|
||||
<button className="tsb-newtask-caret" title="更多创建选项"
|
||||
onClick={e => setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: e.currentTarget })}>▾</button>
|
||||
</div>
|
||||
<input className="tsb-search" placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
</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) }}>🗄</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">▶</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) }}>+</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)}>{e.icon}</button>
|
||||
))}
|
||||
<span style={{ flex: 1 }} />
|
||||
<button title={theme === 'dark' ? '切换浅色' : '切换深色'} onClick={() => store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' })}>{theme === 'dark' ? '☀' : '☾'}</button>
|
||||
</div>
|
||||
|
||||
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
|
||||
<div className="tsb-footer">
|
||||
<button className="tsb-user" onClick={e => setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: e.currentTarget })}>
|
||||
<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 ? 'API 密钥 · 已认证' : '本地模式 · 默认租户') : '…'}</span>
|
||||
</span>
|
||||
<span className="tsb-user-chev">▿</span>
|
||||
</button>
|
||||
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}>⚙</button>
|
||||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.openInbox()}>◍ {pendingBadge}</button>}
|
||||
</div>
|
||||
|
||||
{/* 弹出菜单们 */}
|
||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem 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 onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||||
<MenuItem onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem 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 onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
||||
<MenuItem onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger onClick={() => { auth.logout() }}>退出登录</MenuItem>
|
||||
</>}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface Project {
|
||||
|
||||
export interface Conversation {
|
||||
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
|
||||
pinned?: boolean; archived?: boolean;
|
||||
createdAt: string; updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
+61
-9
@@ -35,13 +35,19 @@ export interface UIState {
|
||||
trackerRunId: string | null
|
||||
viewingArtifact: Artifact | null
|
||||
activeWorkspace: string | null
|
||||
taskOrg: 'project' | 'timeline'
|
||||
showArchived: boolean
|
||||
lastProjectId: string | null
|
||||
}
|
||||
|
||||
export interface Me { owner: string; name: string; auth: boolean }
|
||||
|
||||
interface AppState {
|
||||
contextObs: Record<string, ContextObs>
|
||||
projects: Project[]
|
||||
currentProjectId: string | null
|
||||
conversations: Conversation[]
|
||||
tasks: Conversation[]
|
||||
me: Me | null
|
||||
currentConvId: string | null
|
||||
messages: Message[]
|
||||
runs: Run[]
|
||||
@@ -73,6 +79,9 @@ const DEFAULT_UI: UIState = {
|
||||
settingsOpen: false,
|
||||
trackerRunId: null,
|
||||
viewingArtifact: null,
|
||||
taskOrg: 'project',
|
||||
showArchived: false,
|
||||
lastProjectId: null,
|
||||
}
|
||||
|
||||
function loadUI(): UIState {
|
||||
@@ -85,7 +94,7 @@ function loadUI(): UIState {
|
||||
|
||||
let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
@@ -126,12 +135,23 @@ export const store = {
|
||||
async refreshProjects() {
|
||||
try {
|
||||
const projects = await api.listProjects()
|
||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects[0]
|
||||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||
set({ projects, currentProjectId: current?.id ?? null, error: null })
|
||||
if (current) await store.openProject(current.id)
|
||||
} catch (e) {
|
||||
set({ error: String(e) })
|
||||
}
|
||||
store.refreshTasks()
|
||||
store.refreshMe()
|
||||
},
|
||||
|
||||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||||
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
|
||||
},
|
||||
|
||||
async refreshMe() {
|
||||
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
|
||||
},
|
||||
|
||||
async createProject(name: string) {
|
||||
@@ -142,16 +162,47 @@ export const store = {
|
||||
},
|
||||
|
||||
async openProject(projectId: string) {
|
||||
const conversations = await api.listConversations(projectId)
|
||||
set({ currentProjectId: projectId, conversations })
|
||||
set({ currentProjectId: projectId })
|
||||
store.setUI({ lastProjectId: projectId })
|
||||
await store.refreshArtifacts(projectId)
|
||||
},
|
||||
|
||||
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 */
|
||||
async createTask(projectId?: string) {
|
||||
const pid = projectId ?? state.currentProjectId
|
||||
if (!pid) { store.toast('请先创建项目'); return }
|
||||
const c = await api.createConversation(pid)
|
||||
await store.refreshTasks()
|
||||
await store.openTask(c.id)
|
||||
},
|
||||
|
||||
/** 兼容旧入口(⌘/命令总线) */
|
||||
async createConversation(title?: string) {
|
||||
if (!state.currentProjectId) return
|
||||
const c = await api.createConversation(state.currentProjectId, title)
|
||||
await store.openProject(state.currentProjectId)
|
||||
await store.openConversation(c.id)
|
||||
if (title) store.toast('已在当前项目创建任务')
|
||||
await store.createTask()
|
||||
},
|
||||
|
||||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
||||
async openTask(convId: string) {
|
||||
const t = state.tasks.find(x => x.id === convId)
|
||||
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||||
await store.openConversation(convId)
|
||||
},
|
||||
|
||||
async pinTask(convId: string, pinned: boolean) {
|
||||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t)
|
||||
set({ tasks })
|
||||
try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() }
|
||||
},
|
||||
|
||||
async archiveTask(convId: string, archived: boolean) {
|
||||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t)
|
||||
set({ tasks })
|
||||
try {
|
||||
await api.setConversationFlags(convId, { archived })
|
||||
if (state.currentConvId === convId && archived) store.toast('任务已归档')
|
||||
} catch (e) { store.toast(`归档失败:${e}`) }
|
||||
store.refreshTasks()
|
||||
},
|
||||
|
||||
async openConversation(convId: string) {
|
||||
@@ -178,6 +229,7 @@ export const store = {
|
||||
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
|
||||
set({ plan, todos })
|
||||
store.refreshCards(d.conversation.id)
|
||||
store.refreshTasks()
|
||||
} catch (e) {
|
||||
set({ error: String(e) })
|
||||
}
|
||||
|
||||
+79
-26
@@ -17,27 +17,7 @@ button { font-family: inherit; }
|
||||
.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
|
||||
.right-panel.collapsed { width: 44px !important; }
|
||||
|
||||
/* ---- 左栏 ---- */
|
||||
.nav-header { padding: 14px 14px 8px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
|
||||
.nav-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px; border-radius: var(--radius-sm); }
|
||||
.nav-header button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.nav-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
|
||||
.nav-project { margin-bottom: 4px; }
|
||||
.nav-project-name { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; cursor: pointer; user-select: none; }
|
||||
.nav-project-name:hover { background: var(--bg-hover); }
|
||||
.nav-project-name .chev { font-size: 10px; transition: transform .12s; }
|
||||
.nav-project-name.open .chev { transform: rotate(90deg); }
|
||||
.nav-conv { padding: 5px 8px 5px 26px; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nav-conv:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.nav-conv.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.nav-footer { border-top: 1px solid var(--border); padding: 8px; display: flex; gap: 6px; }
|
||||
.nav-footer button { flex: 1; background: none; border: none; color: var(--text-dim); padding: 7px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; }
|
||||
.nav-footer button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.left-nav.collapsed .nav-label { display: none; }
|
||||
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 10px; }
|
||||
.icon-only button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 8px; border-radius: var(--radius-sm); }
|
||||
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* ---- 左栏:任务中心(方案 A)见文末 tsb-* ---- */
|
||||
/* ---- 中栏对话 ---- */
|
||||
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
|
||||
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
|
||||
@@ -205,11 +185,6 @@ button { font-family: inherit; }
|
||||
.login-err { margin-top: 10px; font-size: 12px; color: #f87171; }
|
||||
|
||||
/* 工作区快捷区(T4/D26 左栏)与当前任务常驻卡(D25 R4) */
|
||||
.ws-quick{padding:6px 8px 8px;border-bottom:1px solid var(--border)}
|
||||
.ws-quick-list{display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
||||
.ws-quick-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:inherit;cursor:pointer;border-radius:6px;text-align:left;font-size:12px}
|
||||
.ws-quick-item:hover{background:rgba(120,160,255,.12)}
|
||||
.ws-quick-item .sc{margin-left:auto;font-size:10px;color:var(--text-faint);border:1px solid var(--border);border-radius:3px;padding:0 4px}
|
||||
.current-task{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:rgba(120,160,255,.05)}
|
||||
.ct-head{display:flex;align-items:center;gap:8px;font-weight:600;margin-bottom:6px}
|
||||
.ct-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
@@ -222,3 +197,81 @@ button { font-family: inherit; }
|
||||
.rail-badge{position:absolute;top:-4px;right:-6px;font-size:10px;background:var(--warn,#d29922);color:#fff;border-radius:8px;padding:0 5px;min-width:14px}
|
||||
.right-panel.collapsed .icon-only button{position:relative}
|
||||
.tab-action{font-size:11px;color:var(--text-faint)}
|
||||
|
||||
/* ---- 左栏:任务中心(方案 A,提案 2026-09-30) ---- */
|
||||
.tsb-brand { display: flex; align-items: center; gap: 8px; padding: 12px 12px 6px; }
|
||||
.tsb-logo { width: 22px; height: 22px; border-radius: 6px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
|
||||
.tsb-name { font-weight: 600; font-size: 13.5px; }
|
||||
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.tsb-actions { padding: 4px 10px 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.tsb-newtask { display: flex; height: 34px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-soft); overflow: hidden; }
|
||||
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: none; border: none; color: var(--accent); font-weight: 600; font-size: 13px; cursor: pointer; min-width: 0; }
|
||||
.tsb-newtask-main:hover { filter: brightness(1.08); }
|
||||
.tsb-newtask-proj { font-weight: 400; font-size: 11.5px; opacity: .8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-newtask-caret { width: 28px; border: none; border-left: 1px solid var(--accent); background: none; color: var(--accent); cursor: pointer; font-size: 10px; }
|
||||
.tsb-newtask-caret:hover { background: var(--accent-soft); }
|
||||
.tsb-search { height: 30px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 10px; font-size: 12.5px; width: 100%; }
|
||||
.tsb-search:focus { border-color: var(--accent); outline: none; }
|
||||
.tsb-search::placeholder { color: var(--text-faint); }
|
||||
|
||||
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 10px 8px; }
|
||||
.tsb-pills { flex: 1; display: flex; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
|
||||
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: 4px; background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; }
|
||||
.tsb-pill:hover { color: var(--text-dim); }
|
||||
.tsb-pill.on { background: var(--bg-active); color: var(--text); font-weight: 500; }
|
||||
|
||||
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 8px 8px; min-height: 0; }
|
||||
.tsb-seclabel { font-size: 11px; color: var(--text-faint); padding: 10px 8px 4px; letter-spacing: .3px; }
|
||||
.tsb-empty { padding: 18px 12px; color: var(--text-faint); font-size: 12.5px; line-height: 1.8; text-align: center; }
|
||||
.tsb-newproject { padding: 0 2px 8px; }
|
||||
|
||||
.tsb-group { margin-bottom: 2px; }
|
||||
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; font-size: 13px; cursor: pointer; user-select: none; }
|
||||
.tsb-group-head:hover { background: var(--bg-hover); }
|
||||
.tsb-group.closed .tsb-chev { transform: rotate(-90deg); }
|
||||
.tsb-chev { font-size: 9px; color: var(--text-faint); transition: transform .12s; }
|
||||
.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-group-cnt { font-size: 11px; color: var(--text-faint); font-weight: 400; }
|
||||
.tsb-group-add { visibility: hidden; width: 20px; height: 20px; border: none; border-radius: 4px; background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; }
|
||||
.tsb-group-head:hover .tsb-group-add { visibility: visible; }
|
||||
.tsb-group-add:hover { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.tsb-task { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; }
|
||||
.tsb-task:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-task.active { background: var(--bg-active); color: var(--text); }
|
||||
.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; }
|
||||
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.tsb-task-tm { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.tsb-pbadge { font-size: 10.5px; color: var(--text-faint); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; line-height: 1.5; white-space: nowrap; }
|
||||
.tsb-row-acts { display: none; gap: 2px; }
|
||||
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
|
||||
.tsb-task:hover .tsb-task-tm { display: none; }
|
||||
.tsb-row-act { border: none; background: none; color: var(--text-faint); font-size: 11px; cursor: pointer; padding: 2px 3px; border-radius: 4px; }
|
||||
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.tsb-wsstrip { display: flex; align-items: center; gap: 2px; padding: 6px 10px; border-top: 1px solid var(--border); }
|
||||
.tsb-wsstrip button { border: none; background: none; color: var(--text-faint); font-size: 13px; cursor: pointer; padding: 4px 5px; border-radius: var(--radius-sm); }
|
||||
.tsb-wsstrip button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
.tsb-footer { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--border); }
|
||||
.tsb-user { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; height: 38px; padding: 0 7px; border: none; border-radius: var(--radius-sm); background: none; cursor: pointer; text-align: left; }
|
||||
.tsb-user:hover { background: var(--bg-hover); }
|
||||
.tsb-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; }
|
||||
.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tsb-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-user-sub { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-user-chev { color: var(--text-faint); font-size: 10px; }
|
||||
|
||||
.tsb-menu { position: fixed; z-index: 300; min-width: 210px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 12px 32px rgba(0,0,0,.35); padding: 5px; display: flex; flex-direction: column; }
|
||||
.tsb-mi { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: 13px; cursor: pointer; text-align: left; }
|
||||
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-mi.danger { color: var(--err); }
|
||||
.tsb-mi.danger:hover { background: var(--err); color: #fff; }
|
||||
.tsb-mi kbd { margin-left: auto; font-size: 10.5px; color: var(--text-faint); }
|
||||
.tsb-menu-div { height: 1px; background: var(--border); margin: 5px 4px; }
|
||||
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
|
||||
.tsb-acc-name { font-size: 13.5px; font-weight: 600; }
|
||||
.tsb-acc-mail { font-size: 11.5px; color: var(--text-faint); }
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// 任务列表组织(方案 A,提案 2026-09-30):纯函数,供 TaskSidebar 与测试消费。
|
||||
// 组织是本端视图偏好(P8),不改数据模型——数据源恒为跨项目 tasks(updated_at 倒序)。
|
||||
import type { Conversation, Project } from './contracts'
|
||||
|
||||
export type TaskOrg = 'project' | 'timeline'
|
||||
|
||||
export interface TaskGroup {
|
||||
key: string
|
||||
label: string
|
||||
/** 项目分组时携带(组头「在此项目新建」入口与计数)。 */
|
||||
projectId?: string
|
||||
tasks: Conversation[]
|
||||
}
|
||||
|
||||
/** 按项目分组:项目按服务端序(更新倒序),组内任务按列表序(置顶已在服务端排首)。 */
|
||||
export function groupByProject(tasks: Conversation[], projects: Project[]): TaskGroup[] {
|
||||
const byProject = new Map<string, Conversation[]>()
|
||||
for (const t of tasks) {
|
||||
const list = byProject.get(t.projectId) ?? []
|
||||
list.push(t)
|
||||
byProject.set(t.projectId, list)
|
||||
}
|
||||
const groups: TaskGroup[] = []
|
||||
for (const p of projects) {
|
||||
const list = byProject.get(p.id)
|
||||
if (!list || list.length === 0) continue
|
||||
groups.push({ key: `prj:${p.id}`, label: p.name, projectId: p.id, tasks: list })
|
||||
byProject.delete(p.id)
|
||||
}
|
||||
// 项目已删但会话仍在(竞态残留):收进"未分组",不静默丢弃。
|
||||
for (const [pid, list] of byProject) {
|
||||
groups.push({ key: `prj:${pid}`, label: '未分组', projectId: pid, tasks: list })
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/** 按时间线分组:今天 / 昨天 / 本周更早 / 更早(按 updatedAt 本地时区)。 */
|
||||
export function groupByTimeline(tasks: Conversation[], now = Date.now()): TaskGroup[] {
|
||||
const startOfDay = (ts: number) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime() }
|
||||
const today0 = startOfDay(now)
|
||||
const dayMs = 86_400_000
|
||||
const buckets: TaskGroup[] = [
|
||||
{ key: 'today', label: '今天', tasks: [] },
|
||||
{ key: 'yesterday', label: '昨天', tasks: [] },
|
||||
{ key: 'week', label: '本周更早', tasks: [] },
|
||||
{ key: 'older', label: '更早', tasks: [] },
|
||||
]
|
||||
const weekday = (new Date(now).getDay() + 6) % 7 // 周一=0
|
||||
const weekStart = today0 - weekday * dayMs
|
||||
for (const t of tasks) {
|
||||
const ts = Date.parse(t.updatedAt)
|
||||
if (Number.isNaN(ts)) { buckets[3].tasks.push(t); continue }
|
||||
if (ts >= today0) buckets[0].tasks.push(t)
|
||||
else if (ts >= today0 - dayMs) buckets[1].tasks.push(t)
|
||||
else if (ts >= weekStart) buckets[2].tasks.push(t)
|
||||
else buckets[3].tasks.push(t)
|
||||
}
|
||||
return buckets.filter(g => g.tasks.length > 0)
|
||||
}
|
||||
|
||||
/** 相对时间(侧栏行尾):刚刚 / N 分钟 / N 小时 / 昨天 / N 天 / 月-日。 */
|
||||
export function relativeTime(iso: string, now = Date.now()): string {
|
||||
const ts = Date.parse(iso)
|
||||
if (Number.isNaN(ts)) return ''
|
||||
const diff = now - ts
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时`
|
||||
if (diff < 172_800_000) return '昨天'
|
||||
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天`
|
||||
const d = new Date(ts)
|
||||
const nowD = new Date(now)
|
||||
const md = `${d.getMonth() + 1}/${d.getDate()}`
|
||||
return d.getFullYear() === nowD.getFullYear() ? md : `${d.getFullYear()}/${md}`
|
||||
}
|
||||
|
||||
/** 标题过滤(不区分大小写;空串返回原列表)。 */
|
||||
export function filterTasks(tasks: Conversation[], q: string): Conversation[] {
|
||||
const needle = q.trim().toLowerCase()
|
||||
if (!needle) return tasks
|
||||
return tasks.filter(t => t.title.toLowerCase().includes(needle))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 任务列表组织纯函数测试(方案 A R1)。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { groupByProject, groupByTimeline, relativeTime, filterTasks } from '../src/tasklist'
|
||||
import type { Conversation, Project } from '../src/contracts'
|
||||
|
||||
const NOW = new Date('2026-09-30T15:00:00').getTime()
|
||||
const H = 3_600_000
|
||||
|
||||
function conv(id: string, projectId: string, updatedAt: string, extra: Partial<Conversation> = {}): Conversation {
|
||||
return { id, projectId, title: id, createdAt: updatedAt, updatedAt, ...extra }
|
||||
}
|
||||
|
||||
describe('groupByProject', () => {
|
||||
it('groups tasks under projects in project order and never drops orphans', () => {
|
||||
const projects: Project[] = [
|
||||
{ id: 'p2', name: '乙', createdAt: '', updatedAt: '' },
|
||||
{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' },
|
||||
]
|
||||
const tasks = [conv('a', 'p1', '2026-09-30T10:00:00Z'), conv('b', 'p2', '2026-09-29T10:00:00Z'), conv('c', 'pX', '2026-09-28T10:00:00Z')]
|
||||
const groups = groupByProject(tasks, projects)
|
||||
expect(groups.map(g => g.label)).toEqual(['乙', '甲', '未分组'])
|
||||
expect(groups[0].projectId).toBe('p2')
|
||||
expect(groups[2].tasks.map(t => t.id)).toEqual(['c'])
|
||||
})
|
||||
it('skips projects with zero visible tasks', () => {
|
||||
const projects: Project[] = [{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' }]
|
||||
expect(groupByProject([], projects)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupByTimeline', () => {
|
||||
it('buckets by local day and week boundaries', () => {
|
||||
// NOW=周三 15:00(本地);构造今天/昨天/本周更早(周一)/更早 各一条。
|
||||
const tasks = [
|
||||
conv('today', 'p', new Date(NOW - H).toISOString()),
|
||||
conv('yesterday', 'p', new Date(NOW - 26 * H).toISOString()),
|
||||
conv('week', 'p', new Date(NOW - 50 * H).toISOString()),
|
||||
conv('older', 'p', new Date(NOW - 10 * 24 * H).toISOString()),
|
||||
]
|
||||
const groups = groupByTimeline(tasks, NOW)
|
||||
expect(groups.map(g => g.label)).toEqual(['今天', '昨天', '本周更早', '更早'])
|
||||
expect(groups[0].tasks[0].id).toBe('today')
|
||||
})
|
||||
it('hides empty buckets and tolerates bad timestamps', () => {
|
||||
const groups = groupByTimeline([conv('x', 'p', 'not-a-date')], NOW)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].label).toBe('更早')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relativeTime', () => {
|
||||
it('formats gradients and year rollover', () => {
|
||||
expect(relativeTime(new Date(NOW - 30_000).toISOString(), NOW)).toBe('刚刚')
|
||||
expect(relativeTime(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 分钟')
|
||||
expect(relativeTime(new Date(NOW - 3 * H).toISOString(), NOW)).toBe('3 小时')
|
||||
expect(relativeTime(new Date(NOW - 30 * H).toISOString(), NOW)).toBe('昨天')
|
||||
expect(relativeTime(new Date(NOW - 3 * 24 * H).toISOString(), NOW)).toBe('3 天')
|
||||
expect(relativeTime('2026-05-01T00:00:00', NOW)).toBe('5/1')
|
||||
expect(relativeTime('2025-12-31T00:00:00', NOW)).toBe('2025/12/31')
|
||||
expect(relativeTime('bad')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterTasks', () => {
|
||||
it('matches title case-insensitively and passes through on empty query', () => {
|
||||
const tasks = [conv('a', 'p', '', { title: '周报 Draft' }), conv('b', 'p', '', { title: '接口评审' })]
|
||||
expect(filterTasks(tasks, '周报')).toEqual([tasks[0]])
|
||||
expect(filterTasks(tasks, ' ')).toEqual(tasks)
|
||||
expect(filterTasks(tasks, 'DRAFT')).toEqual([tasks[0]])
|
||||
})
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/tasklist.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user