Files
Agent-System-Frontend/src/tasklist.ts
T
weichen dc70399d45 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
2026-09-30 10:41:12 +00:00

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))
}