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:
@ -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<Set<string>>(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() {
|
||||
<div className="w-full md:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
|
||||
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
|
||||
「有几件事」,后者只是流量 */}
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{groups.length > 0 && groups.length !== list.length
|
||||
? `${groups.length} 组 · ${list.length} 封`
|
||||
: list.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||
{list.map(m => (
|
||||
<MailItem
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMail?.mail_id === m.mail_id}
|
||||
showTo={isSent}
|
||||
onClick={() => pick(m)}
|
||||
/>
|
||||
))}
|
||||
{groups.map(g =>
|
||||
isFlatGroup(g) ? (
|
||||
<MailItem
|
||||
key={g.latest.mail_id}
|
||||
mail={g.latest}
|
||||
active={currentMail?.mail_id === g.latest.mail_id}
|
||||
showTo={isSent}
|
||||
onClick={() => pick(g.latest)}
|
||||
/>
|
||||
) : (
|
||||
<SessionGroup
|
||||
key={g.sessionId}
|
||||
group={g}
|
||||
showTo={isSent}
|
||||
open={expanded.has(g.sessionId)}
|
||||
onToggle={() => toggle(g.sessionId)}
|
||||
currentMailID={currentMail?.mail_id}
|
||||
onPick={pick}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{list.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">暂无邮件</p>
|
||||
<p className="text-xs text-gray-500 text-center py-6">暂无邮件</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个会话折叠成的一组。
|
||||
*
|
||||
* 组头答的是「哪件事、进行到哪」;展开后才是逐封邮件。
|
||||
* 权限请求不在这里 —— 它们在单独的「授权」导航项里。
|
||||
*/
|
||||
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 (
|
||||
<div
|
||||
className={`rounded-lg border ${
|
||||
hasActive ? 'border-blue-200 bg-blue-50/40' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full text-left px-3 py-2.5 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${
|
||||
open ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs truncate flex-1 font-mono ${
|
||||
g.unreadCount > 0 ? 'font-semibold text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{showTo ? '→ ' : ''}
|
||||
{peer}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-0.5 pl-5">
|
||||
{g.unreadCount > 0 && (
|
||||
<span className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{g.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
g.unreadCount > 0 ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{g.subject}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
||||
<span className="text-[10px] text-blue-500 font-mono truncate">
|
||||
{g.alias ? `.${g.alias}` : '(未命名会话)'}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{g.mails.length} 封</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="pl-3 pr-1 pb-1.5 space-y-0.5">
|
||||
{g.mails.map(m => (
|
||||
<MailItem
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
showTo={showTo}
|
||||
onClick={() => onPick(m)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs truncate flex-1 font-mono ${
|
||||
isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
className={`text-xs truncate flex-1 ${
|
||||
compact ? '' : 'font-mono'
|
||||
} ${isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'}`}
|
||||
>
|
||||
{showTo ? '→ ' : ''}
|
||||
{peer}
|
||||
{compact ? (
|
||||
<>
|
||||
{isUnread && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1.5 align-middle" />
|
||||
)}
|
||||
{mail.subject}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showTo ? '→ ' : ''}
|
||||
{peer}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
|
||||
{isPermission && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
|
||||
{isPermission && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{mail.session_alias && (
|
||||
{/* 组内条目不重复显示别名(组头已有)。
|
||||
发件箱里仍可能出现权限邮件(理论上人发不出,但不假设数据一定干净) */}
|
||||
{compact && isPermission && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] ${
|
||||
mail.permission_result ? 'text-gray-400' : 'text-orange-600 font-medium'
|
||||
}`}
|
||||
>
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{mail.permission_result || '待决策'}
|
||||
</span>
|
||||
)}
|
||||
{!compact && mail.session_alias && (
|
||||
<span className="text-[10px] text-blue-500 font-mono">.{mail.session_alias}</span>
|
||||
)}
|
||||
{ccCount > 0 && (
|
||||
<span className="text-[10px] text-gray-400">抄送 {ccCount}</span>
|
||||
)}
|
||||
{ccCount > 0 && <span className="text-[10px] text-gray-400">抄送 {ccCount}</span>}
|
||||
{attachCount > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||||
|
||||
@ -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<Mail | null>(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 (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
|
||||
@ -60,7 +75,7 @@ export default function MailView() {
|
||||
{forwarding ? (
|
||||
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
|
||||
) : (
|
||||
<ReplyBar replyTo={last} />
|
||||
<ReplyBar replyTo={last} overrideTarget={sessionTarget} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -71,7 +86,7 @@ export default function MailView() {
|
||||
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
|
||||
<div className="text-center">
|
||||
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<p className="text-sm mt-3">选择一封邮件查看,或点击左侧「新建」写邮件</p>
|
||||
<p className="text-sm mt-3 text-gray-500">选择一封邮件查看,或点击左侧「新建」写邮件</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-xs">
|
||||
{isHuman ? (
|
||||
{isMine ? (
|
||||
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
|
||||
) : (
|
||||
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
|
||||
)}
|
||||
<span className="font-semibold text-gray-800 font-mono">
|
||||
{isHuman ? 'human' : mail.from_name}
|
||||
</span>
|
||||
{/* 显示真实发件人名而不是 'human':会话里可能有多个人类参与方,
|
||||
都渲染成 human 就分不清谁说的话 */}
|
||||
<span className="font-semibold text-gray-800 font-mono">{mail.from_name}</span>
|
||||
{isPermission && (
|
||||
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
|
||||
权限请求
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
259
web/src/components/PermissionList.tsx
Normal file
259
web/src/components/PermissionList.tsx
Normal file
@ -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<Set<string> | 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 (
|
||||
<div className="w-full md:w-[340px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">授权</h2>
|
||||
{pendingTotal > 0 ? (
|
||||
<span className="ml-2 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-700 text-[10px] font-semibold">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{pendingTotal} 待决策
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{groups.length > 0 ? `${groups.length} 个会话` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{groups.map(g => (
|
||||
<PermissionSessionGroup
|
||||
key={g.sessionId}
|
||||
group={g}
|
||||
open={openSet.has(g.sessionId)}
|
||||
onToggle={() => toggle(g.sessionId)}
|
||||
currentMailID={currentMail?.mail_id}
|
||||
onPick={pick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{groups.length === 0 && (
|
||||
<div className="text-center py-10">
|
||||
<ShieldIcon className="w-8 h-8 mx-auto text-gray-300" />
|
||||
<p className="text-xs text-gray-400 mt-2">没有授权请求</p>
|
||||
<p className="text-[10px] text-gray-400 mt-1 px-6">
|
||||
Agent 执行敏感操作(bash、写文件)时会在这里请求你批准
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个会话的授权组:组头显示 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 (
|
||||
<div
|
||||
className={`rounded-lg border ${
|
||||
hasPending ? 'border-orange-200 bg-orange-50/50' : 'border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onToggle} className="w-full text-left px-3 py-2.5 rounded-lg">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${
|
||||
open ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
<BotIcon className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">{g.agentName}</span>
|
||||
{g.path && (
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">{g.path}</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-1 pl-5">
|
||||
{hasPending ? (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-orange-700 text-white text-[9px] font-bold">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{g.pending.length} 待决策
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]">
|
||||
<CheckIcon className="w-2.5 h-2.5" />
|
||||
已全部处理
|
||||
</span>
|
||||
)}
|
||||
{g.settled.length > 0 && (
|
||||
<span className="text-[10px] text-gray-400">历史 {g.settled.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-blue-500 font-mono truncate mt-0.5 pl-5">
|
||||
{g.alias ? `.${g.alias}` : '(未命名会话)'}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-2 pb-2 space-y-0.5">
|
||||
{g.pending.map(m => (
|
||||
<PermissionRow
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
onClick={() => onPick(m)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{g.settled.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowSettled(v => !v)}
|
||||
className="w-full text-left px-2 py-1 text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showSettled ? '收起' : '展开'}已决策 {g.settled.length}
|
||||
</button>
|
||||
{showSettled &&
|
||||
g.settled.map(m => (
|
||||
<PermissionRow
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
onClick={() => onPick(m)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一条授权请求。待决策的醒目,已决策的连结果一起显示(批了还是拒了)。 */
|
||||
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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-2.5 py-2 rounded-md border transition-colors ${
|
||||
active
|
||||
? 'bg-blue-50 border-blue-200'
|
||||
: settled
|
||||
? 'border-transparent hover:bg-gray-50'
|
||||
: 'border-orange-200 bg-white hover:bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-xs truncate flex-1 ${
|
||||
settled ? 'text-gray-500' : 'font-medium text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{settled ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] ${
|
||||
approved ? 'text-green-600' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{approved ? <CheckIcon className="w-2.5 h-2.5" /> : <CloseIcon className="w-2.5 h-2.5" />}
|
||||
{mail.permission_result}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-orange-600 font-medium">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
等待你决策
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
145
web/src/lib/replyTarget.ts
Normal file
145
web/src/lib/replyTarget.ts
Normal file
@ -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<string>();
|
||||
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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
321
web/test/components/mailGroups.test.tsx
Normal file
321
web/test/components/mailGroups.test.tsx
Normal file
@ -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> = {}): 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
241
web/test/components/replyTarget.test.tsx
Normal file
241
web/test/components/replyTarget.test.tsx
Normal file
@ -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> = {}): 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']);
|
||||
});
|
||||
});
|
||||
168
web/test/manual/inbox-group-verify.mjs
Normal file
168
web/test/manual/inbox-group-verify.mjs
Normal file
@ -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);
|
||||
Reference in New Issue
Block a user