fix(web): 授权独立成导航项 + 收件箱按会话分组 + 回复对端判定
**授权请求不是「一封信」而是「一件待办」。** 生产取证:一个 pi 会话独占 17 封权限邮件(后涨到 39),另外两个会话各 3 封 / 1 封 —— 收件箱被一件事塞满,失去了它唯一的作用(让人知道有哪几件 事在等我)。 第一版做成会话折叠,用户纠正后重做:权限请求的生命周期是「等人点头 → 决策完就作废」,与普通邮件混在一个箱子里两者互相伤害。收件箱只放要读的, 授权项只放要批的。 `groupPermissions` 的排序判据是「要不要我动手」而非时间:三天前发起、 至今还卡着的授权比十分钟前刚批完的重要得多。纯按时间排会把它压到底部, 而 Agent 那条会话正在等 —— 那正是权限死锁在 UI 上的样子。 未读徽标用红色、待决策用橙色,且两个数字**互不重复计数**:未读是 「有内容没看」,待决策是「有 Agent 卡着等我」。 --- **回复发给自己的 bug(用户报)。** 根因:ReplyBar 的对端判定写死 `from_name === 'human'` —— 单用户时代遗留 (当时人类只有 human@ 一个身份)。登录名是 jianf 时判据恒为假, 于是取 from_name(自己)。 生产链条:`27c22900 jianf→pi` 对(锚点是 pi 发来的), `8e519925 jianf→jianf` 错(锚点是自己发的)。 两处修正: 1. **判据必须是当前登录用户名**,不是字面量 'human'。同一遗留判据在三处: 对端解析 / replyAll 去自己 / ThreadCard 图标。 2. **会话视图的回复对端是会话的属性,不看任何单封邮件。** 人在那儿打字就是「给这次任务的对方追加一句」;用「最后一封」当锚点时, 自己刚发过信就会把自己算成对端。sessionCounterpart 扫全会话取首个 非我参与方(收件人优先于发件人,同刻用 mail_id 定序)。 ThreadCard 顺带显示真实发件人名而不是统一渲染成 'human' —— 会话里可能有 多个人类参与方。 测试:mailGroups 30 例(含「生产实测形状:17 封权限邮件」)、 replyTarget 24 例(含生产链条重现:断言 target 以 pi@ 开头而非 jianf@)。 手工验收脚本 inbox-group-verify.mjs 六项全过。
This commit is contained in:
178
web/src/lib/mailGroups.ts
Normal file
178
web/src/lib/mailGroups.ts
Normal file
@ -0,0 +1,178 @@
|
||||
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<string, Mail[]> {
|
||||
const bySession = new Map<string, Mail[]>();
|
||||
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.from_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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user