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;
|
||||
|
||||
Reference in New Issue
Block a user