From e504eccf3a4b5a0b6c33655edf8c6e31144c194e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 06:29:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=8E=88=E6=9D=83=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=88=90=E5=AF=BC=E8=88=AA=E9=A1=B9=20+=20=E6=94=B6?= =?UTF-8?q?=E4=BB=B6=E7=AE=B1=E6=8C=89=E4=BC=9A=E8=AF=9D=E5=88=86=E7=BB=84?= =?UTF-8?q?=20+=20=E5=9B=9E=E5=A4=8D=E5=AF=B9=E7=AB=AF=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **授权请求不是「一封信」而是「一件待办」。** 生产取证:一个 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 六项全过。 --- web/src/components/MailList.tsx | 251 +++++++++++++++--- web/src/components/MailView.tsx | 72 +++-- web/src/components/PermissionList.tsx | 259 ++++++++++++++++++ web/src/lib/mailGroups.ts | 178 +++++++++++++ web/src/lib/replyTarget.ts | 145 ++++++++++ web/src/stores/uiStore.ts | 2 +- web/test/components/mailGroups.test.tsx | 321 +++++++++++++++++++++++ web/test/components/replyTarget.test.tsx | 241 +++++++++++++++++ web/test/manual/inbox-group-verify.mjs | 168 ++++++++++++ 9 files changed, 1572 insertions(+), 65 deletions(-) create mode 100644 web/src/components/PermissionList.tsx create mode 100644 web/src/lib/mailGroups.ts create mode 100644 web/src/lib/replyTarget.ts create mode 100644 web/test/components/mailGroups.test.tsx create mode 100644 web/test/components/replyTarget.test.tsx create mode 100644 web/test/manual/inbox-group-verify.mjs diff --git a/web/src/components/MailList.tsx b/web/src/components/MailList.tsx index 84f1f2f..7292f22 100644 --- a/web/src/components/MailList.tsx +++ b/web/src/components/MailList.tsx @@ -1,9 +1,10 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useMailStore } from '../stores/mailStore'; import { useSessionStore } from '../stores/sessionStore'; import { useUIStore } from '../stores/uiStore'; import type { Mail } from '../types'; -import { ShieldIcon, PaperclipIcon } from './icons'; +import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups'; +import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons'; export default function MailList() { const viewMode = useUIStore(s => s.viewMode); @@ -18,13 +19,37 @@ export default function MailList() { const fetchSent = useMailStore(s => s.fetchSent); const clearSession = useSessionStore(s => s.clearSession); + // 哪些会话组被展开。默认全部折叠 —— 收件箱的问题正是「一次任务的几十封信 + // 淹掉其他任务」,默认展开等于没分组 + const [expanded, setExpanded] = useState>(new Set()); + useEffect(() => { if (viewMode === 'sent') fetchSent(); else if (viewMode === 'inbox') fetchInbox('all'); }, [viewMode]); + // 切换收件箱/发件箱时收起所有组:两个箱子的会话集合不同, + // 留着上一个箱子的展开状态会让人以为某个组「自己展开了」 + useEffect(() => { + setExpanded(new Set()); + }, [viewMode]); + const isSent = viewMode === 'sent'; - const list = isSent ? sent : inbox; + // 权限请求已经有自己的导航项(授权),收件箱只放要读的内容。 + // 不过滤的后果实测过:一个会话的 17 封权限邮件把另外两个会话的信挤出视野。 + // 发件箱不筛:人发不出权限请求(那是 Agent 发的),筛也筛不掉什么。 + const source = isSent ? sent : splitByPermission(inbox).normal; + const list = source; + const groups = groupMailsBySession(list); + + const toggle = (sessionId: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(sessionId)) next.delete(sessionId); + else next.add(sessionId); + return next; + }); + }; const pick = (m: Mail) => { clearSession(); @@ -40,37 +65,162 @@ export default function MailList() {

{isSent ? '发件箱' : '收件箱'}

- {list.length} + {/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是 + 「有几件事」,后者只是流量 */} + + {groups.length > 0 && groups.length !== list.length + ? `${groups.length} 组 · ${list.length} 封` + : list.length} +
- {list.map(m => ( - pick(m)} - /> - ))} + {groups.map(g => + isFlatGroup(g) ? ( + pick(g.latest)} + /> + ) : ( + toggle(g.sessionId)} + currentMailID={currentMail?.mail_id} + onPick={pick} + /> + ) + )} {list.length === 0 && ( -

暂无邮件

+

暂无邮件

)}
); } +/** + * 一个会话折叠成的一组。 + * + * 组头答的是「哪件事、进行到哪」;展开后才是逐封邮件。 + * 权限请求不在这里 —— 它们在单独的「授权」导航项里。 + */ +function SessionGroup({ + group, + showTo, + open, + onToggle, + currentMailID, + onPick +}: { + group: MailGroup; + showTo: boolean; + open: boolean; + onToggle: () => void; + currentMailID?: string; + onPick: (m: Mail) => void; +}) { + const g = group; + const time = new Date(g.latest.created_at).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + const peer = showTo + ? `${g.latest.to_name}${g.latest.to_workspace ? '@' + g.latest.to_workspace : ''}` + : `${g.latest.from_name}${g.latest.from_workspace ? '@' + g.latest.from_workspace : ''}`; + + // 组内含选中邮件时给个边框,否则展开一个组再滚下去会找不到自己在看哪封 + const hasActive = currentMailID ? g.mails.some(m => m.mail_id === currentMailID) : false; + + return ( +
+ + + {open && ( +
+ {g.mails.map(m => ( + onPick(m)} + compact + /> + ))} +
+ )} +
+ ); +} + function MailItem({ mail, active, showTo, - onClick + onClick, + compact = false }: { mail: Mail; active: boolean; showTo: boolean; onClick: () => void; + /** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */ + compact?: boolean; }) { const isPermission = mail.mail_type === 'permission_request'; const isUnread = mail.status === 'unread'; @@ -96,40 +246,63 @@ function MailItem({ >
- {showTo ? '→ ' : ''} - {peer} + {compact ? ( + <> + {isUnread && ( + + )} + {mail.subject} + + ) : ( + <> + {showTo ? '→ ' : ''} + {peer} + + )} {time}
-
- {isUnread && } - {isPermission && ( - - - 权限 + {!compact && ( +
+ {isUnread && } + {isPermission && ( + + + 权限 + + )} + + {mail.subject} - )} - - {mail.subject} - -
+
+ )}
- {mail.session_alias && ( + {/* 组内条目不重复显示别名(组头已有)。 + 发件箱里仍可能出现权限邮件(理论上人发不出,但不假设数据一定干净) */} + {compact && isPermission && ( + + + {mail.permission_result || '待决策'} + + )} + {!compact && mail.session_alias && ( .{mail.session_alias} )} - {ccCount > 0 && ( - 抄送 {ccCount} - )} + {ccCount > 0 && 抄送 {ccCount}} {attachCount > 0 && ( diff --git a/web/src/components/MailView.tsx b/web/src/components/MailView.tsx index 1d42f16..2cf5dfc 100644 --- a/web/src/components/MailView.tsx +++ b/web/src/components/MailView.tsx @@ -4,6 +4,13 @@ import remarkGfm from 'remark-gfm'; import { useMailStore } from '../stores/mailStore'; import { useSessionStore } from '../stores/sessionStore'; import { useContactStore } from '../stores/contactStore'; +import { useAuthStore } from '../stores/authStore'; +import { + sessionReplyTarget, + mailReplyTarget, + mailCounterpart, + replyAllCC +} from '../lib/replyTarget'; import * as api from '../api/client'; import type { Mail } from '../types'; import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons'; @@ -17,6 +24,10 @@ export default function MailView() { const markRead = useMailStore(s => s.markRead); const currentSession = useSessionStore(s => s.currentSession); const currentSessionMails = useSessionStore(s => s.currentSessionMails); + // 当前登录用户名:判定「哪封是我发的」的唯一基准。 + // 曾经写死成 'human'(单用户时代的遗留),多用户下登录名可能是 jianf, + // 判据恒为假 —— 于是回复自己发的信时对端取成了自己。 + const me = useAuthStore(s => s.user?.username || ''); // 转发面板作用于哪封邮件;null = 未打开 const [forwarding, setForwarding] = useState(null); // 正在看哪封邮件的对话树;null = 看正常的邮件视图 @@ -30,6 +41,10 @@ export default function MailView() { if (currentSession && currentSessionMails.length > 0) { const last = currentSessionMails[currentSessionMails.length - 1]; + // 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定: + // 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的, + // 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。 + const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me); return (
@@ -60,7 +75,7 @@ export default function MailView() { {forwarding ? ( setForwarding(null)} /> ) : ( - + )}
); @@ -71,7 +86,7 @@ export default function MailView() {
-

选择一封邮件查看,或点击左侧「新建」写邮件

+

选择一封邮件查看,或点击左侧「新建」写邮件

); @@ -418,7 +433,10 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) } function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) { - const isHuman = mail.from_name === 'human'; + // 「这封是我发的吗」而不是「发件人叫 human 吗」:多用户下登录名可能是 + // jianf,写死 'human' 会让自己发的信显示成机器人图标。 + const me = useAuthStore(s => s.user?.username || ''); + const isMine = !!me && mail.from_name === me; const isPermission = mail.mail_type === 'permission_request'; const time = new Date(mail.created_at).toLocaleString('zh-CN'); @@ -427,20 +445,20 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) className={`rounded-lg border p-4 ${ isPermission ? 'border-orange-200 bg-orange-50' - : isHuman + : isMine ? 'border-blue-200 bg-blue-50/60' : 'border-gray-200 bg-white' }`} >
- {isHuman ? ( + {isMine ? ( ) : ( )} - - {isHuman ? 'human' : mail.from_name} - + {/* 显示真实发件人名而不是 'human':会话里可能有多个人类参与方, + 都渲染成 human 就分不清谁说的话 */} + {mail.from_name} {isPermission && ( 权限请求 @@ -547,7 +565,19 @@ export function PermissionPanel({ mail }: { mail: Mail }) { ); } -function ReplyBar({ replyTo }: { replyTo?: Mail }) { +function ReplyBar({ + replyTo, + /** + * 会话视图传进来的目标地址,覆盖「按锚点邮件推断」。 + * + * 会话视图的语义是「跟这个 Agent 的一次任务」,对端是会话的属性; + * 单封邮件视图没有这层语境,才回落到按那封邮件推断。 + */ + overrideTarget +}: { + replyTo?: Mail; + overrideTarget?: string; +}) { const [body, setBody] = useState(''); const [cc, setCc] = useState(''); // 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。 @@ -561,15 +591,14 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) { const fetchSessions = useSessionStore(s => s.fetchSessions); const fetchContacts = useContactStore(s => s.fetchContacts); const selectSession = useSessionStore(s => s.selectSession); + const me = useAuthStore(s => s.user?.username || ''); if (!replyTo) return null; - // 回给对端:若这封是我(human)发的,则回给收件人,否则回给发件人 - const peerName = replyTo.from_name === 'human' ? replyTo.to_name : replyTo.from_name; - const peerPath = replyTo.from_name === 'human' ? replyTo.to_workspace : replyTo.from_workspace; - const target = `${peerName}@${peerPath || ''}${ - replyTo.session_alias ? '.' + replyTo.session_alias : '' - }`; + // 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留, + // 多用户下登录名可能是 jianf,判据恒为假 → 对端取成自己 → 信发给自己。 + const peer = mailCounterpart(replyTo, me); + const target = overrideTarget || mailReplyTarget(replyTo, me); const send = async () => { if (!body.trim()) return; @@ -603,16 +632,9 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) { * 那是用户当初写下的原文,重新拼 name@path 会丢掉会话段。 */ const replyAll = () => { - const others = [ - `${replyTo.from_name}${replyTo.from_workspace ? '@' + replyTo.from_workspace : ''}`, - `${replyTo.to_name}${replyTo.to_workspace ? '@' + replyTo.to_workspace : ''}`, - ...(replyTo.cc_list ?? []).map(c => c.raw || c.name) - ] - // 去掉自己与主收件人:前者收不到自己的信没意义,后者已经在 to 里 - .filter(a => a && !a.startsWith('human') && !a.startsWith(peerName)) - // 同一个人可能既在 to 又在 cc 里 - .filter((a, i, arr) => arr.indexOf(a) === i); - setCc(others.join(', ')); + // 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human') + // 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。 + setCc(replyAllCC(replyTo, me, peer.name).join(', ')); setCcOpen(true); }; diff --git a/web/src/components/PermissionList.tsx b/web/src/components/PermissionList.tsx new file mode 100644 index 0000000..17e9c34 --- /dev/null +++ b/web/src/components/PermissionList.tsx @@ -0,0 +1,259 @@ +import { useEffect, useState } from 'react'; +import { useMailStore } from '../stores/mailStore'; +import { useSessionStore } from '../stores/sessionStore'; +import { useUIStore } from '../stores/uiStore'; +import type { Mail } from '../types'; +import { groupPermissions, type PermissionGroup } from '../lib/mailGroups'; +import { ShieldIcon, ChevronRightIcon, CheckIcon, CloseIcon, BotIcon } from './icons'; + +/** + * 授权列表:一级是会话,二级是该会话的授权请求。 + * + * 独立于收件箱存在,因为权限请求不是「一封信」而是「一件待办」—— + * 它的生命周期是「等人点头 → 决策完就作废」,混进收件箱两者互相伤害: + * 一次 Agent 任务能产生十几个权限请求(每个被拦下的 bash/write 都是一封), + * 把真正需要阅读的来信压到看不见的地方;反过来,人要找「有什么在等我批」 + * 也得在几十封信里翻。生产实测一个会话独占 17 封权限邮件。 + */ +export default function PermissionList() { + const inbox = useMailStore(s => s.inbox); + const currentMail = useMailStore(s => s.currentMail); + const selectMail = useMailStore(s => s.selectMail); + const fetchInbox = useMailStore(s => s.fetchInbox); + const clearSession = useSessionStore(s => s.clearSession); + const cancelCompose = useUIStore(s => s.cancelCompose); + const showDetail = useUIStore(s => s.showDetail); + + const groups = groupPermissions(inbox); + const pendingTotal = groups.reduce((n, g) => n + g.pending.length, 0); + + // 有待决策请求的会话默认展开:那些是在等人动手的,藏起来等于没解决问题。 + // 全部已决策的会话默认折叠 —— 它们只是历史。 + const [expanded, setExpanded] = useState | null>(null); + const autoOpen = groups.filter(g => g.pending.length > 0).map(g => g.sessionId); + const openSet = expanded ?? new Set(autoOpen); + + useEffect(() => { + fetchInbox('all'); + }, []); + + const toggle = (sessionId: string) => { + setExpanded(prev => { + const next = new Set(prev ?? autoOpen); + if (next.has(sessionId)) next.delete(sessionId); + else next.add(sessionId); + return next; + }); + }; + + const pick = (m: Mail) => { + clearSession(); + cancelCompose(); + selectMail(m); + showDetail(); + }; + + return ( +
+
+

授权

+ {pendingTotal > 0 ? ( + + + {pendingTotal} 待决策 + + ) : ( + + {groups.length > 0 ? `${groups.length} 个会话` : ''} + + )} +
+ +
+ {groups.map(g => ( + toggle(g.sessionId)} + currentMailID={currentMail?.mail_id} + onPick={pick} + /> + ))} + + {groups.length === 0 && ( +
+ +

没有授权请求

+

+ Agent 执行敏感操作(bash、写文件)时会在这里请求你批准 +

+
+ )} +
+
+ ); +} + +/** 一个会话的授权组:组头显示 Agent 与待决策数,展开后是逐条请求。 */ +function PermissionSessionGroup({ + group: g, + open, + onToggle, + currentMailID, + onPick +}: { + group: PermissionGroup; + open: boolean; + onToggle: () => void; + currentMailID?: string; + onPick: (m: Mail) => void; +}) { + const [showSettled, setShowSettled] = useState(false); + const hasPending = g.pending.length > 0; + const time = new Date(g.latest.created_at).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + return ( +
+ + + {open && ( +
+ {g.pending.map(m => ( + onPick(m)} + /> + ))} + + {g.settled.length > 0 && ( + <> + + {showSettled && + g.settled.map(m => ( + onPick(m)} + /> + ))} + + )} +
+ )} +
+ ); +} + +/** 一条授权请求。待决策的醒目,已决策的连结果一起显示(批了还是拒了)。 */ +function PermissionRow({ + mail, + active, + onClick +}: { + mail: Mail; + active: boolean; + onClick: () => void; +}) { + const settled = !!mail.permission_result; + const approved = settled && /同意|允许|批准|approve|yes/i.test(mail.permission_result || ''); + const time = new Date(mail.created_at).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + return ( + + ); +} diff --git a/web/src/lib/mailGroups.ts b/web/src/lib/mailGroups.ts new file mode 100644 index 0000000..9a903a8 --- /dev/null +++ b/web/src/lib/mailGroups.ts @@ -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 { + 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.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); + }); +} diff --git a/web/src/lib/replyTarget.ts b/web/src/lib/replyTarget.ts new file mode 100644 index 0000000..6110a3a --- /dev/null +++ b/web/src/lib/replyTarget.ts @@ -0,0 +1,145 @@ +import type { Mail, Session } from '../types'; + +/** + * 「这封回复该发给谁」。 + * + * 原先这件事被写成一行三元表达式,判据是 `from_name === 'human'` —— + * 那是多用户认证之前的遗留:当时人类只有一个身份 `human@`。改成多用户后 + * `users.username` 与 `agents.agent_name` 共用命名空间,登录名可能是 `jianf`, + * 于是判据恒为假,回复对端就取成了 `from_name`(也就是自己)。 + * + * 后果是**信发给了自己**:在会话视图里回复时尤其必然发生 —— 那里的锚点是 + * 「最后一封」,而最后一封常常就是自己刚发的那封。生产实测链条: + * pi → jianf 权限请求 + * jianf → pi Re: 权限请求(对的,因为锚点是 pi 发来的) + * pi → jianf 权限请求 + * jianf → jianf Re: Re: 权限请求(错的,锚点是自己发的) + * + * 更根本的问题是**判据本身选错了**。会话视图的语义是「跟这个 Agent 的一次 + * 任务」,人在这里打字就是「给对方追加一句」;对端是**会话的属性**, + * 不该由「最后一封是谁发的」这种偶然状态决定。所以会话视图用 + * `sessionCounterpart` 扫全会话定对端,只有单封邮件视图才用 `mailCounterpart`。 + */ + +/** 一个可投递的对端。 */ +export interface Counterpart { + name: string; + /** 工作目录,可能为空(人类没有工作目录) */ + path: string; +} + +/** 拼三维地址 `name@path.session`。session 为空时省略该段。 */ +export function formatAddress(name: string, path: string, session?: string | null): string { + if (!name) return ''; + const base = `${name}@${path || ''}`; + return session ? `${base}.${session}` : base; +} + +/** + * 单封邮件的对端:我发的就回给收件人,别人发的就回给发件人。 + * + * `me` 必须是当前登录用户名。传空串时退化为「回给发件人」—— + * 那比回给自己安全:最坏的情况是回错人,而不是把信发进虚空。 + */ +export function mailCounterpart(mail: Mail, me: string): Counterpart { + const iSent = !!me && mail.from_name === me; + return iSent + ? { name: mail.to_name, path: mail.to_workspace || '' } + : { name: mail.from_name, path: mail.from_workspace || '' }; +} + +/** + * 会话的对端:扫全会话找第一个不是我的参与方。 + * + * 为什么扫全会话而不看某一封:会话视图里人的意图是「给这次任务的对方追加一句」, + * 对端是会话的属性。只看最后一封时,自己刚发过信就会把自己算成对端。 + * + * 按时间正序扫,取**首个**非我参与方 —— 会话的发起对象就是这次任务的主体, + * 后来被抄送进来的第三方不该抢走这个位置。同名参与方保留**首个非空 path**: + * 同名 Agent 在不同目录是不同的活,而某些邮件的 workspace 字段可能为空。 + */ +export function sessionCounterpart(mails: Mail[], me: string): Counterpart | null { + if (!mails.length) return null; + + // 与后端 `ORDER BY created_at ASC, mail_id ASC` 一致: + // SQLite 时间戳精度有限,同刻插入的多封靠 mail_id 定序 + const sorted = [...mails].sort((a, b) => { + const ta = new Date(a.created_at).getTime(); + const tb = new Date(b.created_at).getTime(); + const na = Number.isNaN(ta) ? 0 : ta; + const nb = Number.isNaN(tb) ? 0 : tb; + return na !== nb ? na - nb : a.mail_id.localeCompare(b.mail_id); + }); + + let found: Counterpart | null = null; + for (const m of sorted) { + // 收件人优先于发件人:会话首封多是「我 → Agent」, + // 那个 to_name 就是这次任务派给了谁 + const candidates: Counterpart[] = [ + { name: m.to_name, path: m.to_workspace || '' }, + { name: m.from_name, path: m.from_workspace || '' } + ]; + for (const c of candidates) { + if (!c.name || c.name === me) continue; + if (!found) { + found = c; + } else if (found.name === c.name && !found.path && c.path) { + // 补上首次出现时缺失的 path + found = c; + } + if (found.path) return found; + } + } + return found; +} + +/** + * 会话视图的回复目标地址。 + * + * 会话别名必须带上:不带就落到该 Agent 的**默认会话**, + * 而人明明是在某条具体线索里打字 —— 那会让追加的一句跑到另一条任务里去。 + */ +export function sessionReplyTarget( + mails: Mail[], + session: Session | null, + me: string +): string { + const peer = sessionCounterpart(mails, me); + if (!peer) return ''; + return formatAddress(peer.name, peer.path, session?.session_alias || null); +} + +/** 单封邮件视图的回复目标地址。 */ +export function mailReplyTarget(mail: Mail, me: string): string { + const peer = mailCounterpart(mail, me); + return formatAddress(peer.name, peer.path, mail.session_alias || null); +} + +/** + * 「回复全部」的抄送清单:会话/邮件的其他参与方,去掉自己与主收件人。 + * + * 原先用 `!a.startsWith('human')` 去掉自己 —— 同一个遗留判据, + * 结果是点「回复全部」会把自己抄送进去。 + */ +export function replyAllCC(mail: Mail, me: string, primaryName: string): string[] { + const raw = [ + formatAddress(mail.from_name, mail.from_workspace || ''), + formatAddress(mail.to_name, mail.to_workspace || ''), + // cc_list 取 raw:那是用户当初写下的原文,重新拼会丢掉会话段 + ...(mail.cc_list ?? []).map(c => c.raw || c.name) + ]; + + const seen = new Set(); + const out: string[] = []; + for (const addr of raw) { + if (!addr) continue; + const name = addr.split('@')[0]; + // 自己收不到自己的信没意义;主收件人已经在 to 里 + if (me && name === me) continue; + if (primaryName && name === primaryName) continue; + if (seen.has(addr)) continue; + seen.add(addr); + out.push(addr); + } + return out; +} diff --git a/web/src/stores/uiStore.ts b/web/src/stores/uiStore.ts index 29cf425..eedc143 100644 --- a/web/src/stores/uiStore.ts +++ b/web/src/stores/uiStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -export type ViewMode = 'inbox' | 'sent' | 'contacts' | 'admin' | 'account'; +export type ViewMode = 'inbox' | 'sent' | 'permissions' | 'calendar' | 'contacts' | 'admin' | 'account'; interface UIState { viewMode: ViewMode; diff --git a/web/test/components/mailGroups.test.tsx b/web/test/components/mailGroups.test.tsx new file mode 100644 index 0000000..ad8f832 --- /dev/null +++ b/web/test/components/mailGroups.test.tsx @@ -0,0 +1,321 @@ +import { describe, it, expect } from 'vitest'; +import { + groupMailsBySession, + isFlatGroup, + isPendingPermission, + splitByPermission, + countPendingPermissions, + groupPermissions +} from '../../src/lib/mailGroups'; +import type { Mail } from '../../src/types'; + +/** + * 收件箱会话分组 + 授权请求独立分组。 + * + * 这些用例锁的是生产实测过的形状:一个会话独占 17 封权限邮件, + * 把另外两个会话的信挤出视野。修法有两层 —— + * 权限请求整体移出收件箱(各归各的导航项),剩下的普通邮件按会话折叠。 + */ + +let seq = 0; +function mail(over: Partial = {}): Mail { + seq += 1; + return { + mail_id: `m${String(seq).padStart(3, '0')}`, + session_id: 's1', + parent_mail_id: null, + from_name: 'pi', + from_workspace: '/home', + to_name: 'jianf', + to_workspace: '', + cc_list: [], + subject: `主题 ${seq}`, + body: '正文', + mail_type: 'normal', + permission_options: null, + permission_result: null, + status: 'read', + created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`, + ...over + }; +} + +function perm(over: Partial = {}): Mail { + return mail({ mail_type: 'permission_request', ...over }); +} + +describe('groupMailsBySession', () => { + it('把同一会话的多封邮件折成一组', () => { + const groups = groupMailsBySession([ + mail({ session_id: 'a' }), + mail({ session_id: 'a' }), + mail({ session_id: 'a' }), + mail({ session_id: 'b' }) + ]); + expect(groups).toHaveLength(2); + expect(groups.find(g => g.sessionId === 'a')!.mails).toHaveLength(3); + }); + + it('组内按时间倒序,最新那封做组头', () => { + const old = mail({ session_id: 'a', created_at: '2026-09-03T08:00:00Z', subject: '旧' }); + const mid = mail({ session_id: 'a', created_at: '2026-09-03T09:00:00Z', subject: '中' }); + const now = mail({ session_id: 'a', created_at: '2026-09-03T10:00:00Z', subject: '新' }); + const [g] = groupMailsBySession([mid, old, now]); // 故意乱序传入 + expect(g.mails.map(m => m.subject)).toEqual(['新', '中', '旧']); + expect(g.latest.subject).toBe('新'); + // 组标题取最新一封:会话主题随任务推进被改写,最新的最贴切 + expect(g.subject).toBe('新'); + }); + + it('组之间按最新邮件时间倒序', () => { + const groups = groupMailsBySession([ + mail({ session_id: 'stale', created_at: '2026-09-01T10:00:00Z' }), + mail({ session_id: 'fresh', created_at: '2026-09-03T10:00:00Z' }), + mail({ session_id: 'mid', created_at: '2026-09-02T10:00:00Z' }) + ]); + expect(groups.map(g => g.sessionId)).toEqual(['fresh', 'mid', 'stale']); + }); + + it('同一时刻用 mail_id 兜底定序(SQLite 时间戳精度有限)', () => { + const ts = '2026-09-03T10:00:00Z'; + const a = mail({ mail_id: 'aaa', session_id: 's', created_at: ts }); + const z = mail({ mail_id: 'zzz', session_id: 's', created_at: ts }); + const [g1] = groupMailsBySession([a, z]); + const [g2] = groupMailsBySession([z, a]); + // 两种输入顺序必须给出同一结果,否则「刷新一次顺序就变了」 + expect(g1.mails.map(m => m.mail_id)).toEqual(g2.mails.map(m => m.mail_id)); + expect(g1.mails[0].mail_id).toBe('zzz'); + }); + + it('统计未读数', () => { + const [g] = groupMailsBySession([ + mail({ session_id: 's', status: 'unread' }), + mail({ session_id: 's', status: 'unread' }), + mail({ session_id: 's', status: 'read' }) + ]); + expect(g.unreadCount).toBe(2); + }); + + it('别名取自最新一封;无别名给空串而不是 undefined', () => { + const [withAlias] = groupMailsBySession([ + mail({ session_id: 's', session_alias: 'deploy-review' }) + ]); + expect(withAlias.alias).toBe('deploy-review'); + const [without] = groupMailsBySession([mail({ session_id: 's' })]); + expect(without.alias).toBe(''); + }); + + it('session_id 缺失的脏数据各自成组而不是挤成一堆', () => { + const groups = groupMailsBySession([ + mail({ mail_id: 'x', session_id: '' }), + mail({ mail_id: 'y', session_id: '' }) + ]); + // 全归到 '' 这一组会把两封无关的信显示成一个会话 + expect(groups).toHaveLength(2); + }); + + it('created_at 解析失败不会让顺序变得不确定', () => { + const bad = mail({ mail_id: 'bad', session_id: 's', created_at: '不是时间' }); + const good = mail({ mail_id: 'good', session_id: 's', created_at: '2026-09-03T10:00:00Z' }); + const [g] = groupMailsBySession([bad, good]); + // NaN 参与比较恒为 false,会让排序结果取决于原数组顺序 + expect(g.mails.map(m => m.mail_id)).toEqual(['good', 'bad']); + }); + + it('不修改入参数组', () => { + const input = [ + mail({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }), + mail({ session_id: 's', created_at: '2026-09-03T10:00:00Z' }) + ]; + const snapshot = input.map(m => m.mail_id); + groupMailsBySession(input); + expect(input.map(m => m.mail_id)).toEqual(snapshot); + }); + + it('空数组返回空数组', () => { + expect(groupMailsBySession([])).toEqual([]); + }); +}); + +describe('isFlatGroup', () => { + it('单封邮件的组平铺显示,不套折叠头', () => { + const [g] = groupMailsBySession([mail({ session_id: 'solo' })]); + expect(isFlatGroup(g)).toBe(true); + }); + + it('两封以上才折叠', () => { + const [g] = groupMailsBySession([mail({ session_id: 's' }), mail({ session_id: 's' })]); + expect(isFlatGroup(g)).toBe(false); + }); +}); + +describe('splitByPermission', () => { + it('权限请求与普通邮件分开', () => { + const { normal, permissions } = splitByPermission([ + mail({ subject: '来信' }), + perm({ subject: '要跑 bash' }), + mail({ subject: '又一封' }) + ]); + expect(normal.map(m => m.subject)).toEqual(['来信', '又一封']); + expect(permissions.map(m => m.subject)).toEqual(['要跑 bash']); + }); + + it('保持原有顺序(调用方自己排序)', () => { + const a = mail({ mail_id: 'a' }); + const b = mail({ mail_id: 'b' }); + expect(splitByPermission([a, b]).normal.map(m => m.mail_id)).toEqual(['a', 'b']); + }); + + it('空输入给两个空数组而不是 undefined', () => { + const r = splitByPermission([]); + expect(r.normal).toEqual([]); + expect(r.permissions).toEqual([]); + }); +}); + +describe('isPendingPermission', () => { + it('普通邮件不是权限请求', () => { + expect(isPendingPermission(mail())).toBe(false); + }); + + it('permission_result 为空串或 null 都算未决策', () => { + // 后端用 COALESCE(permission_result,'') 归一,两种都会出现 + expect(isPendingPermission(perm({ permission_result: null }))).toBe(true); + expect(isPendingPermission(perm({ permission_result: '' }))).toBe(true); + }); + + it('有决策结果就不再是待决策', () => { + expect(isPendingPermission(perm({ permission_result: '拒绝' }))).toBe(false); + }); +}); + +describe('countPendingPermissions', () => { + it('只数待决策的那些', () => { + const n = countPendingPermissions([ + perm({ permission_result: null }), + perm({ permission_result: '' }), + perm({ permission_result: '同意' }), + mail() + ]); + expect(n).toBe(2); + }); + + it('没有待决策时给 0', () => { + expect(countPendingPermissions([perm({ permission_result: '同意' })])).toBe(0); + }); +}); + +describe('groupPermissions', () => { + it('只收权限请求,普通邮件不进来', () => { + const groups = groupPermissions([ + mail({ session_id: 'a' }), + perm({ session_id: 'a' }), + perm({ session_id: 'a' }) + ]); + expect(groups).toHaveLength(1); + expect(groups[0].pending.length + groups[0].settled.length).toBe(2); + }); + + it('待决策与已决策分开装', () => { + const [g] = groupPermissions([ + perm({ session_id: 's', permission_result: null, subject: '在等' }), + perm({ session_id: 's', permission_result: '同意', subject: '批过' }), + perm({ session_id: 's', permission_result: '拒绝', subject: '拒过' }) + ]); + expect(g.pending.map(m => m.subject)).toEqual(['在等']); + expect(g.settled.map(m => m.subject).sort()).toEqual(['批过', '拒过']); + }); + + it('有待决策的会话排在前面,哪怕它更旧', () => { + const groups = groupPermissions([ + // 刚刚批完的 + perm({ session_id: 'done', permission_result: '同意', created_at: '2026-09-03T12:00:00Z' }), + // 三天前就卡着的 —— 那条 Agent 会话正在那儿等 + perm({ session_id: 'stuck', permission_result: null, created_at: '2026-08-31T09:00:00Z' }) + ]); + expect(groups.map(g => g.sessionId)).toEqual(['stuck', 'done']); + }); + + it('都有待决策时,卡住更多请求的会话更靠前', () => { + const groups = groupPermissions([ + perm({ session_id: 'one', permission_result: null, created_at: '2026-09-03T12:00:00Z' }), + perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:00:00Z' }), + perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:01:00Z' }), + perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:02:00Z' }) + ]); + expect(groups[0].sessionId).toBe('many'); + }); + + it('都无待决策时按最新时间倒序', () => { + const groups = groupPermissions([ + perm({ session_id: 'old', permission_result: '同意', created_at: '2026-09-01T10:00:00Z' }), + perm({ session_id: 'new', permission_result: '同意', created_at: '2026-09-03T10:00:00Z' }) + ]); + expect(groups.map(g => g.sessionId)).toEqual(['new', 'old']); + }); + + it('组头带上发起请求的 Agent 与工作目录', () => { + const [g] = groupPermissions([ + perm({ session_id: 's', from_name: 'dsh', from_workspace: '/home/program/llmsproxy' }) + ]); + // 同名 Agent 在不同目录是不同的活,光有名字判断不了 + expect(g.agentName).toBe('dsh'); + expect(g.path).toBe('/home/program/llmsproxy'); + }); + + it('组内时间倒序', () => { + const [g] = groupPermissions([ + perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T08:00:00Z', subject: '早' }), + perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T10:00:00Z', subject: '晚' }) + ]); + expect(g.pending.map(m => m.subject)).toEqual(['晚', '早']); + }); + + it('没有权限请求时返回空数组', () => { + expect(groupPermissions([mail(), mail()])).toEqual([]); + }); + + it('不修改入参数组', () => { + const input = [ + perm({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }), + perm({ session_id: 's', created_at: '2026-09-03T10:00:00Z' }) + ]; + const snapshot = input.map(m => m.mail_id); + groupPermissions(input); + expect(input.map(m => m.mail_id)).toEqual(snapshot); + }); +}); + +describe('生产实测形状:17 封权限邮件的会话', () => { + it('权限请求移出收件箱后,剩下的信按会话分组且不再被淹', () => { + const flood: Mail[] = []; + for (let i = 0; i < 17; i++) { + flood.push( + perm({ + session_id: 'f3ce62b0', + permission_result: i === 16 ? null : '同意', + created_at: `2026-09-03T09:${String(i).padStart(2, '0')}:00Z` + }) + ); + } + const realMail = [ + mail({ session_id: 'fa420c33', created_at: '2026-09-03T10:00:00Z', subject: '进展汇报' }), + mail({ session_id: 'c2db6b62', created_at: '2026-09-03T11:00:00Z', subject: '需要确认' }) + ]; + const inbox = [...flood, ...realMail]; + + // 收件箱侧:权限请求整体不进来,只剩两封真信 + const { normal, permissions } = splitByPermission(inbox); + expect(normal).toHaveLength(2); + expect(permissions).toHaveLength(17); + const inboxGroups = groupMailsBySession(normal); + expect(inboxGroups.map(g => g.subject)).toEqual(['需要确认', '进展汇报']); + + // 授权侧:17 封折成 1 组,只有 1 个在等人 + const permGroups = groupPermissions(permissions); + expect(permGroups).toHaveLength(1); + expect(permGroups[0].pending).toHaveLength(1); + expect(permGroups[0].settled).toHaveLength(16); + expect(countPendingPermissions(permissions)).toBe(1); + }); +}); diff --git a/web/test/components/replyTarget.test.tsx b/web/test/components/replyTarget.test.tsx new file mode 100644 index 0000000..aeea73e --- /dev/null +++ b/web/test/components/replyTarget.test.tsx @@ -0,0 +1,241 @@ +import { describe, it, expect } from 'vitest'; +import { + formatAddress, + mailCounterpart, + sessionCounterpart, + sessionReplyTarget, + mailReplyTarget, + replyAllCC +} from '../../src/lib/replyTarget'; +import type { Mail, Session } from '../../src/types'; + +/** + * 「这封回复该发给谁」。 + * + * 锁的是一次生产事故:判据写死 `from_name === 'human'`(单用户时代的遗留), + * 多用户下登录名是 jianf,判据恒为假 → 对端取成 from_name(自己)→ 信发给自己。 + * 数据库里的链条: + * pi → jianf 权限请求 + * jianf → pi Re: 权限请求 (对的,锚点是 pi 发来的) + * pi → jianf 权限请求 + * jianf → jianf Re: Re: 权限请求 (错的,锚点是自己发的) + */ + +let seq = 0; +function mail(over: Partial = {}): Mail { + seq += 1; + return { + mail_id: `m${String(seq).padStart(3, '0')}`, + session_id: 's1', + parent_mail_id: null, + from_name: 'pi', + from_workspace: '/home/program/llmsproxy', + to_name: 'jianf', + to_workspace: '', + cc_list: [], + subject: '主题', + body: '正文', + mail_type: 'normal', + permission_options: null, + permission_result: null, + status: 'read', + created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`, + ...over + }; +} + +const session = (alias: string | null): Session => ({ + session_id: 's1', + session_alias: alias, + from_agent: 'jianf', + subject: '关于llmsproxy工程的联合审查', + status: 'active', + created_at: '2026-09-03T09:00:00Z', + updated_at: '2026-09-03T10:00:00Z' +}); + +describe('formatAddress', () => { + it('三段齐全', () => { + expect(formatAddress('pi', '/home', 'deploy')).toBe('pi@/home.deploy'); + }); + it('无会话段时省略', () => { + expect(formatAddress('pi', '/home')).toBe('pi@/home'); + expect(formatAddress('pi', '/home', null)).toBe('pi@/home'); + }); + it('path 为空仍保留 @(人类没有工作目录)', () => { + expect(formatAddress('jianf', '')).toBe('jianf@'); + }); + it('名字为空给空串而不是拼出 @', () => { + expect(formatAddress('', '/home')).toBe(''); + }); +}); + +describe('mailCounterpart', () => { + it('别人发来的 → 回给发件人', () => { + const m = mail({ from_name: 'pi', to_name: 'jianf' }); + expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' }); + }); + + it('我发出的 → 回给收件人(不是回给自己)', () => { + const m = mail({ + from_name: 'jianf', + from_workspace: '', + to_name: 'pi', + to_workspace: '/home/program/llmsproxy' + }); + // 这一条就是生产 bug:原代码在这里返回 jianf + expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy' }); + }); + + it('登录名未知时退化为回给发件人,不会回给自己', () => { + const m = mail({ from_name: 'pi', to_name: 'jianf' }); + expect(mailCounterpart(m, '').name).toBe('pi'); + }); + + it('不把 human 当特殊值', () => { + // 老判据写死 human;现在它只是个普通名字 + const m = mail({ from_name: 'human', to_name: 'pi' }); + expect(mailCounterpart(m, 'jianf').name).toBe('human'); + expect(mailCounterpart(m, 'human').name).toBe('pi'); + }); +}); + +describe('sessionCounterpart', () => { + it('按会话定对端,不受最后一封是谁发的影响', () => { + const mails = [ + mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }), + mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }), + // 最后一封是我自己发的 —— 原代码在这里会把自己当对端 + mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:00:00Z' }) + ]; + expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi'); + }); + + it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => { + const mails = [ + mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '2026-09-03T09:00:00Z' }), + mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }) + ]; + expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi'); + }); + + it('补上首次出现时缺失的 path', () => { + const mails = [ + // 首封的 to_workspace 是空的 + mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '', created_at: '2026-09-03T09:00:00Z' }), + // 后一封才带上目录 + mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }) + ]; + // 同名 Agent 在不同目录是不同的活,path 不能丢 + expect(sessionCounterpart(mails, 'jianf')).toEqual({ + name: 'pi', + path: '/home/program/llmsproxy' + }); + }); + + it('同刻邮件用 mail_id 定序,结果稳定', () => { + const ts = '2026-09-03T09:00:00Z'; + const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: ts }); + const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: ts }); + expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name); + }); + + it('全是自己的会话返回 null 而不是自己', () => { + const mails = [mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf' })]; + expect(sessionCounterpart(mails, 'jianf')).toBeNull(); + }); + + it('空会话返回 null', () => { + expect(sessionCounterpart([], 'jianf')).toBeNull(); + }); + + it('created_at 解析失败不影响确定性', () => { + const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_workspace: '', to_name: 'pi', created_at: '不是时间' }); + const good = mail({ mail_id: 'good', from_name: 'jianf', from_workspace: '', to_name: 'dsh', created_at: '2026-09-03T09:00:00Z' }); + expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe( + sessionCounterpart([good, bad], 'jianf')?.name + ); + }); +}); + +describe('sessionReplyTarget', () => { + it('带上会话别名:不带会落到该 Agent 的默认会话', () => { + const mails = [ + mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy' }) + ]; + expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe( + 'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查' + ); + }); + + it('会话未命名时省略会话段', () => { + const mails = [ + mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home' }) + ]; + expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home'); + }); + + it('生产链条重现:回复自己发的那封仍指向 pi', () => { + const mails = [ + mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }), + mail({ from_name: 'jianf', from_workspace: '', to_name: 'pi', to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }), + mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }), + mail({ from_name: 'jianf', from_workspace: '', to_name: 'jianf', created_at: '2026-09-03T10:56:34Z' }) + ]; + const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf'); + expect(target.startsWith('pi@')).toBe(true); + expect(target.startsWith('jianf@')).toBe(false); + }); + + it('找不到对端时给空串(调用方据此禁用发送)', () => { + expect(sessionReplyTarget([], session('x'), 'jianf')).toBe(''); + }); +}); + +describe('mailReplyTarget', () => { + it('单封视图带上该封的会话别名', () => { + const m = mail({ from_name: 'pi', to_name: 'jianf', session_alias: 'deploy-review' }); + expect(mailReplyTarget(m, 'jianf')).toBe('pi@/home/program/llmsproxy.deploy-review'); + }); +}); + +describe('replyAllCC', () => { + it('去掉自己与主收件人', () => { + const m = mail({ + from_name: 'pi', + from_workspace: '/home', + to_name: 'jianf', + to_workspace: '', + cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }] + }); + // 主收件人是 pi(回复对象),自己是 jianf —— 都不该出现在抄送里 + expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']); + }); + + it('自己是 jianf 时不会把自己抄送进去(老判据只挡 human)', () => { + const m = mail({ from_name: 'pi', from_workspace: '/home', to_name: 'jianf', to_workspace: '' }); + expect(replyAllCC(m, 'jianf', 'pi')).toEqual([]); + }); + + it('cc_list 取 raw 保留会话段', () => { + const m = mail({ + from_name: 'pi', + from_workspace: '/home', + to_name: 'jianf', + cc_list: [{ name: 'dsh', path: '/opt', session: 'audit', raw: 'dsh@/opt.audit' }] + }); + // 重新拼 name@path 会丢掉 .audit + expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.audit']); + }); + + it('同一个人既在 to 又在 cc 时只出现一次', () => { + const m = mail({ + from_name: 'pi', + from_workspace: '/home', + to_name: 'dsh', + to_workspace: '/opt', + cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }] + }); + expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']); + }); +}); diff --git a/web/test/manual/inbox-group-verify.mjs b/web/test/manual/inbox-group-verify.mjs new file mode 100644 index 0000000..78b57e9 --- /dev/null +++ b/web/test/manual/inbox-group-verify.mjs @@ -0,0 +1,168 @@ +/** + * 收件箱分组 + 授权独立列表的实测验收。 + * + * 起因:生产库里一个会话独占 17 封权限邮件,把另外两个会话的信挤出视野 + * —— 收件箱失去了它唯一的作用(让人知道有哪几件事在等我)。 + * + * 修法两层,这里各验一层: + * 1. 权限请求整体移出收件箱,进「授权」导航项(一级会话 / 二级请求) + * 2. 收件箱剩下的普通邮件按会话折叠 + * + * 单元测试(test/components/mailGroups.test.tsx)守住分组函数的算术, + * 量不出「组头真的只有一行」「折叠时组内邮件确实不在 DOM 里」这些 + * 只有真实渲染才能验的事。 + * + * 用法:ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \ + * node web/test/manual/inbox-group-verify.mjs + */ +import { openApp, WIDE } from './narrow-probe-helper.mjs'; + +const { browser, page, issues } = await openApp(WIDE); +const failed = []; + +async function check(name, fn) { + try { + const r = await fn(); + console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`); + if (!r.ok) failed.push(name); + } catch (e) { + console.log(` 错误 ${name} — ${e.message.slice(0, 100)}`); + failed.push(name); + } +} + +/** 中间栏里可点的条目数(组头 + 展开出来的行)。 */ +const rowCount = () => page.locator('div.overflow-y-auto button').count(); + +async function goto(label) { + await page.click(`aside button[title*="${label}"], button[title*="${label}"]`); + await page.waitForTimeout(1200); +} + +// ───────────────── 收件箱:权限已移出、其余按会话折叠 ───────────────── +console.log('\n收件箱:'); + +await goto('收件箱'); + +await check('权限请求不再出现在收件箱', async () => { + const txt = await page.locator('div.overflow-y-auto').innerText(); + // 「待决策」「等待你决策」都是授权列表的措辞;收件箱里不该有 + const leaked = /待决策|等待你决策/.test(txt); + return { ok: !leaked, note: leaked ? '收件箱里出现了权限措辞' : '干净' }; +}); + +await check('多封邮件的会话折叠成一行', async () => { + const header = await page.locator('h2:has-text("收件箱")').locator('..').innerText(); + const m = header.match(/(\d+)\s*组\s*·\s*(\d+)\s*封/); + if (!m) { + // 每个会话都只有一封时不显示「N 组 · M 封」,这是设计(单封平铺) + return { ok: true, note: `无可折叠会话:${header.replace(/\n/g, ' ').trim()}` }; + } + return { ok: Number(m[1]) < Number(m[2]), note: `${m[1]} 组 / ${m[2]} 封` }; +}); + +// ───────────────── 授权:一级会话 / 二级请求 ───────────────── +console.log('\n授权列表:'); + +await goto('授权'); + +await check('侧栏有独立的「授权」入口', async () => { + const n = await page.locator('button[title*="授权"]').count(); + return { ok: n > 0, note: `${n} 个入口` }; +}); + +await check('权限请求按会话分组(一级是会话)', async () => { + const heads = await page.locator('div.overflow-y-auto > div > button').count(); + const txt = await page.locator('div.overflow-y-auto').innerText(); + if (heads === 0 && /没有授权请求/.test(txt)) { + return { ok: true, note: '当前无授权请求(空态正常)' }; + } + // 组头带会话别名(.alias 或「(未命名会话)」) + const hasAlias = /\.\S+|\(未命名会话\)/.test(txt); + return { ok: heads > 0 && hasAlias, note: `${heads} 个会话组头` }; +}); + +await check('待决策的会话默认展开(在等人的不能藏)', async () => { + const txt = await page.locator('div.overflow-y-auto').innerText(); + if (/没有授权请求/.test(txt)) return { ok: true, note: '无授权请求,跳过' }; + const pendingBadge = await page.locator('span:has-text("待决策")').count(); + if (pendingBadge === 0) { + return { ok: /已全部处理/.test(txt), note: '全部已处理,组头显示「已全部处理」' }; + } + // 有待决策 → 组内应已展开,能看到「等待你决策」的行 + const rows = await page.locator('span:has-text("等待你决策")').count(); + return { ok: rows > 0, note: `${pendingBadge} 个待决策徽标,${rows} 行展开可见` }; +}); + +await check('已决策的历史折进二级,不默认铺开', async () => { + const toggle = page.locator('button:has-text("已决策")'); + const n = await toggle.count(); + if (n === 0) return { ok: true, note: '无已决策历史,跳过' }; + const label = await toggle.first().innerText(); + return { ok: label.includes('展开'), note: label.trim() }; +}); + +await check('二级折叠可展开', async () => { + const toggle = page.locator('button:has-text("已决策")'); + if ((await toggle.count()) === 0) return { ok: true, note: '无二级折叠,跳过' }; + const before = await rowCount(); + await toggle.first().click(); + await page.waitForTimeout(600); + const after = await rowCount(); + return { ok: after > before, note: `${before} → ${after} 个条目` }; +}); + +await check('点一条授权请求能打开决策面板', async () => { + // 授权行带「等待你决策」或决策结果;组头带「待决策 N」或「已全部处理」 + const row = page + .locator('div.overflow-y-auto button') + .filter({ hasText: /等待你决策|同意|拒绝/ }) + .filter({ hasNotText: /待决策 \d|已全部处理|展开已决策|收起已决策/ }); + if ((await row.count()) === 0) return { ok: true, note: '无授权请求,跳过' }; + await row.first().click(); + await page.waitForTimeout(1500); + // 右栏出现权限决策按钮或已决策的结果说明 + const panel = + (await page.locator('button:has-text("同意")').count()) > 0 || + (await page.locator('text=/已(同意|拒绝|决策)/').count()) > 0; + return { ok: panel, note: panel ? '决策面板已渲染' : '右栏没有内容' }; +}); + +await check('组头可收起', async () => { + const head = page.locator('div.overflow-y-auto > div > button').first(); + if ((await head.count()) === 0) return { ok: true, note: '无组头,跳过' }; + const before = await rowCount(); + await head.click(); + await page.waitForTimeout(600); + const after = await rowCount(); + // 原本折叠的组点一下会展开;原本展开的会收起。两种都算「可切换」 + return { ok: after !== before, note: `${before} → ${after}` }; +}); + +// ───────────────── 徽标语义 ───────────────── +console.log('\n徽标:'); + +await check('收件箱未读数不把权限请求算进来', async () => { + const badge = page.locator('button[title*="收件箱"] span').filter({ hasText: /^\d+$/ }); + const inboxBadge = (await badge.count()) > 0 ? await badge.first().innerText() : '0'; + const permBadge = page.locator('button[title*="授权"] span').filter({ hasText: /^\d+$/ }); + const pBadge = (await permBadge.count()) > 0 ? await permBadge.first().innerText() : '0'; + // 两个数字各自独立;一个待批的 bash 不该在两处都计数 + return { ok: true, note: `收件箱未读 ${inboxBadge},授权待决策 ${pBadge}` }; +}); + +await check('无 JS 运行时错误', async () => { + // 404 资源(favicon 之类)不算 JS 错误 + const real = issues.filter(i => !/404|Failed to load resource/.test(i)); + return { ok: real.length === 0, note: real.length ? real.slice(0, 2).join(' | ') : '无' }; +}); + +console.log( + failed.length === 0 + ? '\n收件箱分组 + 授权列表:全部通过' + : `\n收件箱分组 + 授权列表:${failed.length} 项失败 — ${failed.join('、')}` +); + +await page.close(); +await browser.close(); +process.exit(failed.length === 0 ? 0 : 1);