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:
2026-09-04 06:29:21 +08:00
parent 5b76ac5c57
commit e504eccf3a
9 changed files with 1572 additions and 65 deletions

178
web/src/lib/mailGroups.ts Normal file
View 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);
});
}