>(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) => (
+
+
+
+
+ )
+
+ const taskRow = (t: Conversation, badge?: string) => (
+ store.openTask(t.id)}
+ onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
+ {t.title || '未命名任务'}
+ {badge && {badge}}
+ {relativeTime(t.updatedAt)}
+ {rowActions(t)}
+
+ )
+
+ return (
+ <>
+
+ A
+ Agent-System
+
+
+
+
+ {/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
+
+
+
+
+
+
setQ(e.target.value)} />
+
+
+ {/* 组织视图切换(P8)+ 过滤 */}
+
+
+
+
+
+
+
+
+
+ {creatingProject && (
+
+ 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="项目名称,回车创建" />
+
+ )}
+
+ {pinned.length > 0 && (
+ <>
+
置顶
+ {pinned.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
+ >
+ )}
+
+ {groups.length === 0 && pinned.length === 0 && (
+
尚无任务——点击「新建任务」开始
(项目内素材上传、检索与生成全程可在对话中完成)
+ )}
+
+ {groups.map(g => (
+
+ {org === 'project' ? (
+
tglGroup(g.key)}>
+ ▶
+ {g.label}
+ {g.tasks.length}
+
+
+
+ ) : (
+
{g.label}
+ )}
+ {!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
+
+ ))}
+
+
+ {/* 工作区快捷图标条(D25:与 ⌘1–9 同一清单) */}
+
+ {workspaceEntries().map(e => (
+
+ ))}
+
+
+
+
+ {/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
+
+
+
+ {pendingBadge > 0 && }
+
+
+ {/* 弹出菜单们 */}
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/contracts.ts b/src/contracts.ts
index fe3a488..7c45ebf 100644
--- a/src/contracts.ts
+++ b/src/contracts.ts
@@ -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;
}
diff --git a/src/store.ts b/src/store.ts
index 48cda25..4aa31d1 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -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
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) })
}
diff --git a/src/styles.css b/src/styles.css
index 00571b9..9b91458 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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); }
diff --git a/src/tasklist.ts b/src/tasklist.ts
new file mode 100644
index 0000000..e5228bf
--- /dev/null
+++ b/src/tasklist.ts
@@ -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()
+ 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))
+}
diff --git a/test/tasklist.test.ts b/test/tasklist.test.ts
new file mode 100644
index 0000000..96f14a8
--- /dev/null
+++ b/test/tasklist.test.ts
@@ -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 {
+ 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]])
+ })
+})
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index 00c22a1..1bda813 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -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"}
\ No newline at end of file
+{"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"}
\ No newline at end of file