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
83 lines
3.5 KiB
TypeScript
83 lines
3.5 KiB
TypeScript
// 任务列表组织(方案 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))
|
|
}
|