Files
MailUI4Agents/client/electron/src/components/MailList.tsx

328 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups';
import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons';
import { participantAddress } from '../lib/replyTarget';
export default function MailList() {
const viewMode = useUIStore(s => s.viewMode);
const cancelCompose = useUIStore(s => s.cancelCompose);
const showDetail = useUIStore(s => s.showDetail);
const inbox = useMailStore(s => s.inbox);
const sent = useMailStore(s => s.sent);
const currentMail = useMailStore(s => s.currentMail);
const selectMail = useMailStore(s => s.selectMail);
const fetchInbox = useMailStore(s => s.fetchInbox);
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';
// 权限请求已经有自己的导航项(授权),收件箱只放要读的内容。
// 不过滤的后果实测过:一个会话的 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();
cancelCompose();
selectMail(m);
// 窄屏下列表与详情共用一栏,选中后要切过去;
// 宽屏下这个状态不影响渲染(两栏并排),但仍然维护 ——
// 否则从窄屏拖宽再拖回来,用户会发现自己回到了列表,刚打开的邮件不见了
showDetail();
};
return (
<div className="w-full lg: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-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">
{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-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'
});
// 列表行只显示「跟谁在通信」,**不带会话位**:分组头下面已经单独显示了
// 会话别名,再拼一遍会让长别名(实测 92 字节)把这一行挤没。
//
// 人还是 Agent 走显式布尔workspace 从会话取from_workspace 对 Agent
// 存的是 Agent 名而非路径)。
const isPeerHuman = showTo ? g.latest.to_human : g.latest.from_human;
const peer = participantAddress(
showTo ? g.latest.to_name : g.latest.from_name,
isPeerHuman,
isPeerHuman ? '' : g.latest.session_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-600 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-5 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,
compact = false
}: {
mail: Mail;
active: boolean;
showTo: boolean;
onClick: () => void;
/** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */
compact?: boolean;
}) {
const isPermission = mail.mail_type === 'permission_request';
const isUnread = mail.status === 'unread';
const ccCount = mail.cc_list?.length ?? 0;
const attachCount = mail.attachments?.length ?? 0;
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const isRowHuman = showTo ? mail.to_human : mail.from_human;
const peer = participantAddress(
showTo ? mail.to_name : mail.from_name,
isRowHuman,
isRowHuman ? '' : mail.session_workspace || ''
);
return (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-colors ${
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2">
<span
className={`text-xs truncate flex-1 ${
compact ? '' : 'font-mono'
} ${isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'}`}
>
{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>
{!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>
</div>
)}
<div className="flex items-center gap-2 mt-0.5">
{/* 组内条目不重复显示别名(组头已有)。
发件箱里仍可能出现权限邮件(理论上人发不出,但不假设数据一定干净) */}
{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="min-w-0 flex-1 truncate text-[10px] text-blue-600 font-mono">.{mail.session_alias}</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" />
{attachCount}
</span>
)}
</div>
</button>
);
}