import { useEffect, useRef, useState } from 'react'; import { useMailStore } from '../stores/mailStore'; import { useSessionStore } from '../stores/sessionStore'; import { useUIStore } from '../stores/uiStore'; import type { Mail } from '../types'; import { groupPermissions, type PermissionGroup } from '../lib/mailGroups'; import { ShieldIcon, ChevronRightIcon, CheckIcon, CloseIcon, BotIcon } from './icons'; /** * 授权列表:一级是会话,二级是该会话的授权请求。 * * 独立于收件箱存在,因为权限请求不是「一封信」而是「一件待办」—— * 它的生命周期是「等人点头 → 决策完就作废」,混进收件箱两者互相伤害: * 一次 Agent 任务能产生十几个权限请求(每个被拦下的 bash/write 都是一封), * 把真正需要阅读的来信压到看不见的地方;反过来,人要找「有什么在等我批」 * 也得在几十封信里翻。生产实测一个会话独占 17 封权限邮件。 */ export default function PermissionList() { const inbox = useMailStore(s => s.inbox); const currentMail = useMailStore(s => s.currentMail); const selectMail = useMailStore(s => s.selectMail); const fetchInbox = useMailStore(s => s.fetchInbox); const clearSession = useSessionStore(s => s.clearSession); const cancelCompose = useUIStore(s => s.cancelCompose); const showDetail = useUIStore(s => s.showDetail); const groups = groupPermissions(inbox); const pendingTotal = groups.reduce((n, g) => n + g.pending.length, 0); // 有待决策请求的会话默认展开:那些是在等人动手的,藏起来等于没解决问题。 // 全部已决策的会话默认折叠 —— 它们只是历史。 const [expanded, setExpanded] = useState | null>(null); const autoOpen = groups.filter(g => g.pending.length > 0).map(g => g.sessionId); const openSet = expanded ?? new Set(autoOpen); /** * 新到手的待决请求必须让它所在的会话**自己打开**。 * * 为什么需要这一段:`expanded` 一旦被手动点过一次,`expanded ?? autoOpen` * 就永远取前者 —— 那份快照冻结在"点的那一刻"。此后新到的待决请求会落在 * 一个折叠的分组里:**徽标数字变了,内容却看不见**,直到离开再回到授权页 * (组件重挂载、`expanded` 回到 null、默认展开重算)才出现。 * 线上实测复现过:授权 2 → 3 而新请求正文不可见,重进页面才可见。 * * 判据是**新出现的待决邮件**(`sessionId:mailId`),不是"会话有没有待决": * 会话早就有待决、用户把它折叠了,之后**又来了新的一条** —— 那一条同样必须 * 露出来(这正是实测里撞到的情形)。而用户在这之后对同一条的折叠不会被弹开, * 因为没有新的 mail_id 出现。 */ const pendingKey = groups .flatMap(g => g.pending.map(m => `${g.sessionId}:${m.mail_id}`)) .sort() .join(','); const prevPendingRef = useRef(''); useEffect(() => { const prev = new Set(prevPendingRef.current ? prevPendingRef.current.split(',') : []); prevPendingRef.current = pendingKey; const newly = (pendingKey ? pendingKey.split(',') : []).filter(k => !prev.has(k)); if (newly.length === 0) return; const sessions = Array.from(new Set(newly.map(k => k.split(':')[0]))); // expanded === null 时本来就走 autoOpen,不需要动 setExpanded(prevSet => (prevSet === null ? prevSet : new Set([...prevSet, ...sessions]))); }, [pendingKey]); useEffect(() => { fetchInbox('all'); }, []); const toggle = (sessionId: string) => { setExpanded(prev => { const next = new Set(prev ?? autoOpen); if (next.has(sessionId)) next.delete(sessionId); else next.add(sessionId); return next; }); }; const pick = (m: Mail) => { clearSession(); cancelCompose(); selectMail(m); showDetail(); }; return (

授权

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

没有授权请求

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

)}
); } /** 一个会话的授权组:组头显示 Agent 与待决策数,展开后是逐条请求。 */ function PermissionSessionGroup({ group: g, open, onToggle, currentMailID, onPick }: { group: PermissionGroup; open: boolean; onToggle: () => void; currentMailID?: string; onPick: (m: Mail) => void; }) { const [showSettled, setShowSettled] = useState(false); const hasPending = g.pending.length > 0; const time = new Date(g.latest.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return (
{open && (
{g.pending.map(m => ( onPick(m)} /> ))} {g.settled.length > 0 && ( <> {showSettled && g.settled.map(m => ( onPick(m)} /> ))} )}
)}
); } /** 一条授权请求。待决策的醒目,已决策的连结果一起显示(批了还是拒了)。 */ function PermissionRow({ mail, active, onClick }: { mail: Mail; active: boolean; onClick: () => void; }) { const settled = !!mail.permission_result; const approved = settled && /同意|允许|批准|approve|yes/i.test(mail.permission_result || ''); // 待办是否已越过等待窗口:拿服务端给的**时刻**与本地当前时间比。 // // 服务端刻意不发「是否失效」的布尔值 —— 那是发出那一刻的快照,经 SSE 缓存到 // 本地后会永久停在 false,于是界面会把一条早就没人等的待办一直显示成「等待你 // 决策」。时刻是持久事实,任何时刻都能自己算出结论。 const expired = !settled && !!mail.permission_expires_at && Date.now() > Date.parse(mail.permission_expires_at); const time = new Date(mail.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return ( ); }