import { useEffect, useState } from 'react'; import Markdown from 'react-markdown'; 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, participantAddress } 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'; import AddressInput from './AddressInput'; import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments'; import ThreadView from './ThreadView'; import PermissionChip, { permissionModeHint } from './PermissionChip'; import BackButton from './BackButton'; export default function MailView() { const currentMail = useMailStore(s => s.currentMail); const markRead = useMailStore(s => s.markRead); const currentSession = useSessionStore(s => s.currentSession); const currentSessionMails = useSessionStore(s => s.currentSessionMails); // 当前登录用户名:判定「哪封是我发的」的唯一基准。 // 曾经写死成 'human'(单用户时代的遗留),多用户下登录名可能是 jianf, // 判据恒为假 —— 于是回复自己发的信时对端取成了自己。 const me = useAuthStore(s => s.user?.username || ''); // 转发面板作用于哪封邮件;null = 未打开 const [forwarding, setForwarding] = useState(null); // 正在看哪封邮件的对话树;null = 看正常的邮件视图 const [threadOf, setThreadOf] = useState(null); // 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索 const currentMailID = currentMail?.mail_id; useEffect(() => { setThreadOf(null); }, [currentMailID]); if (currentSession && currentSessionMails.length > 0) { const last = currentSessionMails[currentSessionMails.length - 1]; // 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定: // 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的, // 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。 const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me); return (
{currentSession.session_alias ? `.${currentSession.session_alias}` : '(未命名会话)'} {currentSessionMails.length} 封

{currentSession.subject}

{currentSessionMails.map(m => ( setForwarding(m)} /> ))}
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 —— 而人多数时间待在会话视图里,等于转发功能在 UI 上找不到 */} {forwarding ? ( setForwarding(null)} /> ) : ( )}
); } if (!currentMail) { return (

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

); } if (threadOf) { return setThreadOf(null)} />; } return (
markRead(currentMail.mail_id)} onForward={() => setForwarding(currentMail)} onThread={() => setThreadOf(currentMail.mail_id)} />
{currentMail.body}
{currentMail.mail_type === 'permission_request' && ( )}
{forwarding ? ( setForwarding(null)} /> ) : ( )}
); } /** * 权限档位编辑器(会话头部)。 * * 人在派活时声明「这件事允许 Agent 动手到什么程度」。 * 只有新会话时在 ComposePage 里设;对话页里随时可改(规划档位)。 * 三档:plan(只读)/ workspace(目录内,越界问人)/ full(全权)。 */ function PermissionEditor() { const session = useSessionStore(s => s.currentSession); const setPermissionMode = useSessionStore(s => s.setPermissionMode); const [editing, setEditing] = useState(false); const [busy, setBusy] = useState(false); if (!session) return null; const mode = session.permission_mode || 'workspace'; const enforcement = session.permission_enforcement || 'advisory'; if (!editing) { return ( ); } const modes = [ { value: 'plan', label: '只读', desc: '不许写/改/执行' }, { value: 'workspace', label: '目录内', desc: '越界问人' }, { value: 'full', label: '全权', desc: '自动放行' }, ]; return (
{modes.map(o => ( ))}
); } /** * 本任务的往返预算编辑器(会话头部)。 * * 配额最该被编辑的地方就是这里:人看着往来内容才知道这件事还值不值得再来几个回合。 * 放在管理员页面调某个 Agent 的全局配额是另一回事 —— 那管的是「这个 Agent 总共能发多少」, * 而不是「这件事值得多少个来回」。 */ function BudgetEditor() { const budget = useSessionStore(s => s.budget); const setBudget = useSessionStore(s => s.setBudget); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(''); const [busy, setBusy] = useState(false); if (!budget) return null; const exhausted = !budget.unlimited && budget.remaining === 0; const open = () => { setDraft(budget.unlimited ? '' : String(budget.max_rounds)); setEditing(true); }; const commit = async (patch: { max_rounds?: number; reset?: boolean }) => { setBusy(true); await setBudget(patch); setBusy(false); setEditing(false); }; if (!editing) { return ( ); } const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim()); return (
往返预算 setDraft(e.target.value)} inputMode="numeric" placeholder="不限" autoFocus className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${ invalid ? 'border-red-300' : 'border-gray-300' }`} />
); } /** * Agent 提议改会话别名的提示条。 * * 为什么要人点头而不是让 Agent 直接改:别名是**人**的寻址入口(name@path.别名)。 * Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。 * 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。 */ function RenameProposalBar() { const proposal = useSessionStore(s => s.renameProposal); const current = useSessionStore(s => s.currentSession); const accept = useSessionStore(s => s.acceptRename); const dismiss = useSessionStore(s => s.dismissRename); const [busy, setBusy] = useState(false); if (!proposal) return null; const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)'; return (

Agent 建议把会话别名从 {from} 改为{' '} .{proposal.alias}

{proposal.reason && (

{proposal.reason}

)}

改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。 接受后此别名不再被 Agent 平台的自动命名覆盖

); } /** * 转发面板。与回复并列,二者互斥显示 —— 同时开两个输入框会让人不知道自己在写哪个。 * 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。 */ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) { const [to, setTo] = useState(''); const [cc, setCc] = useState(''); const [ccOpen, setCcOpen] = useState(false); const [comment, setComment] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const fetchInbox = useMailStore(s => s.fetchInbox); const fetchSent = useMailStore(s => s.fetchSent); const fetchSessions = useSessionStore(s => s.fetchSessions); const fetchContacts = useContactStore(s => s.fetchContacts); const submit = async () => { if (!to.trim() || busy) return; setBusy(true); setError(null); try { await api.forwardMail(mail.mail_id, { to: to.trim(), cc: cc.trim(), comment: comment.trim() }); await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]); onClose(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; return (
转发「{mail.subject}」
{ccOpen && ( )}