import type { Mail } from '../types'; /** * 邮件列表的两种分组:收件箱按会话折叠,授权请求单独成项。 * * 为什么需要它:`/me/mail/inbox` 返回的是平铺的邮件流(`ORDER BY created_at DESC`), * 而一次 Agent 任务会在同一会话里产生几十封邮件 —— 权限询问尤其密集, * 每个被拦下的 bash/write 都是一封。实测生产库里一个会话独占 17 封权限邮件, * 把整个收件箱挤满,另外两个会话的信被压到看不见的地方。 * * 缺了分组的后果不是「不好看」,而是收件箱失去了它唯一的作用: * 让人知道「有哪几件事在等我」。17 行同一件事和 3 件不同的事,占的视觉权重一样。 */ /** 一个会话在列表里折叠成的一组。 */ export interface MailGroup { sessionId: string; /** 会话别名,空串表示尚未命名 */ alias: string; /** 组标题:取最新一封的主题(会话主题会随任务推进被改写,最新的那个最贴切) */ subject: string; /** 最新一封,组头的摘要与时间都取自它 */ latest: Mail; /** 组内全部邮件,时间倒序 */ mails: Mail[]; unreadCount: number; } function timeOf(m: Mail): number { const t = new Date(m.created_at).getTime(); // created_at 解析失败时给 0 而不是 NaN:NaN 参与比较恒为 false, // 会让排序结果依赖于原数组顺序,表现为「刷新一次顺序就变了」 return Number.isNaN(t) ? 0 : t; } /** 时间倒序;同一时刻用 mail_id 兜底,与后端 `ORDER BY created_at DESC, mail_id DESC` 一致。 */ function byNewest(a: Mail, b: Mail): number { const d = timeOf(b) - timeOf(a); return d !== 0 ? d : b.mail_id.localeCompare(a.mail_id); } /** * 按 session_id 把邮件桶化。 * * session_id 缺失的邮件(理论上不该有,但前端不该因为一条脏数据整栏空白) * 各自成组:用 mail_id 兜底键,保证它至少能被看见。 */ function bucketBySession(mails: Mail[], keep: (m: Mail) => boolean): Map { const bySession = new Map(); for (const m of mails) { if (!keep(m)) continue; const key = m.session_id || `mail:${m.mail_id}`; const bucket = bySession.get(key); if (bucket) bucket.push(m); else bySession.set(key, [m]); } return bySession; } /** * 把平铺的邮件流按 session_id 折叠成组,组之间按最新邮件时间倒序。 * * 不修改入参;对同一输入永远给出同一输出(组内、组间都有确定的排序), * 因此可以直接在 render 里调用。 */ export function groupMailsBySession(mails: Mail[]): MailGroup[] { const groups: MailGroup[] = []; for (const [sessionId, bucket] of bucketBySession(mails, () => true)) { const sorted = [...bucket].sort(byNewest); const latest = sorted[0]; groups.push({ sessionId, alias: latest.session_alias || '', subject: latest.subject, latest, mails: sorted, unreadCount: sorted.filter(m => m.status === 'unread').length }); } return groups.sort((a, b) => byNewest(a.latest, b.latest)); } /** * 单封邮件的组不算「组」,平铺显示即可。 * * 给一封孤立的邮件套上可折叠的组头会多一次点击才能读到内容, * 而收件箱里大多数人类来信就是孤立的一封。 */ export function isFlatGroup(g: MailGroup): boolean { return g.mails.length === 1; } /** 权限邮件且尚无决策结果。空串与 null 都算未决策(后端用 COALESCE 归一成空串)。 */ export function isPendingPermission(m: Mail): boolean { return m.mail_type === 'permission_request' && !m.permission_result; } /** * 把权限请求从普通邮件里分出来。 * * 权限请求不是「一封信」而是「一件待办」:它的生命周期是「等人点头 → 决策完就作废」, * 而普通邮件是要读的内容。混在一个收件箱里两者互相伤害 —— * 一次 Agent 任务能产生十几个权限请求,把真正需要阅读的来信压到看不见的地方; * 反过来,人要找「有什么在等我批」也得在几十封信里翻。 * * 所以它们各归各的导航项:收件箱只放要读的,授权项只放要批的。 */ export function splitByPermission(mails: Mail[]): { normal: Mail[]; permissions: Mail[] } { const normal: Mail[] = []; const permissions: Mail[] = []; for (const m of mails) { if (m.mail_type === 'permission_request') permissions.push(m); else normal.push(m); } return { normal, permissions }; } /** 待决策的权限请求数 —— 授权项的徽标数字,也是「要人动手」的唯一信号。 */ export function countPendingPermissions(mails: Mail[]): number { let n = 0; for (const m of mails) if (isPendingPermission(m)) n += 1; return n; } /** 授权项里的一个会话分组:一级是会话,二级是该会话的授权请求。 */ export interface PermissionGroup { sessionId: string; alias: string; /** 发起请求的 Agent(权限请求一定由 Agent 发出) */ agentName: string; /** Agent 的工作目录,同名 Agent 在不同目录是不同的活 */ path: string; /** 还等着人点头的,时间倒序 */ pending: Mail[]; /** 已决策的历史记录,时间倒序 */ settled: Mail[]; /** 组内最新一封,组头时间取自它 */ latest: Mail; } /** * 把权限请求按会话折成组,**有待决策的会话永远排在前面**。 * * 排序判据不是时间而是「要不要我动手」:一个三天前发起、至今还卡着的授权请求 * 比十分钟前刚批完的那条重要得多。纯按时间排会把它压到列表底部, * 而 Agent 那条会话正在那儿等着 —— 这正是权限死锁在 UI 上的样子。 */ export function groupPermissions(mails: Mail[]): PermissionGroup[] { const groups: PermissionGroup[] = []; for (const [sessionId, bucket] of bucketBySession( mails, m => m.mail_type === 'permission_request' )) { const sorted = [...bucket].sort(byNewest); const latest = sorted[0]; groups.push({ sessionId, alias: latest.session_alias || '', agentName: latest.from_name, path: latest.session_workspace || '', pending: sorted.filter(isPendingPermission), settled: sorted.filter(m => !isPendingPermission(m)), latest }); } return groups.sort((a, b) => { // 有待决策的先来;组内待决策数多的更靠前(那条会话卡得更久) if ((a.pending.length > 0) !== (b.pending.length > 0)) { return a.pending.length > 0 ? -1 : 1; } if (a.pending.length !== b.pending.length) return b.pending.length - a.pending.length; return byNewest(a.latest, b.latest); }); }