Files
MailUI4Agents/client/electron/src/components/PermissionList.tsx
JianFeeeee 77699e216b fix(webui): 新到的授权请求藏在折叠分组里 —— 徽标动了,内容看不见
用户报告:"我点到授权界面,才更新显示授权请求"。

先排除了推送本身:实测徽标是**实时**更新的(gui-lab 授权 7→8、jianf 1→2,
都没导航)。问题在内容:`PermissionList` 的展开状态
`const openSet = expanded ?? new Set(autoOpen)` —— 一旦手动点过一次,
`expanded` 就冻结成"点的那一刻"的快照,此后新到的待决请求落在一个折叠的分组里:
徽标数字变了,正文却看不见,直到离开再回到授权页(组件重挂载、`expanded`
回到 null、默认展开重算)才出现。

这违反代码自己的设计意图(注释写着「有待决策请求的会话默认展开:那些是在等人
动手的,藏起来等于没解决问题」)。

修法:加一条**状态迁移**判据 —— 新出现的待决邮件(`sessionId:mailId`)让它所在
的会话自动展开。用 mail_id 而不是"会话有没有待决"作判据,是因为实测撞到的正是
"会话早就有待决、用户把它折叠了,之后又来了一条";而用户在那之后再手动折叠同一
条不会被弹开(没有新 mail_id)。

验证:
  · 真浏览器复现:授权 2 → 3 而新请求正文不可见,重进页面才可见(复现成功)
  · 新增 `test/components/PermissionList-autopen.test.tsx`(3 条,含反向对照)
  · 扰动验证:撤掉修复 → 2 条目标判据红、对照判据仍绿;恢复 → 3/3
  · 部署后同一探针复验:折叠状态下新请求**立刻可见**,不再需要重进页面
  · 前端 239 测试全绿;桌面重打包与 WebUI 同源(index-jaRgHqX2.js)

顺带修掉一个**更严重的缺陷**(在做「用 zcode 写个网页」时被 agent 自己报出来的):

  fix(plugins): zcode 的 read_mail 永远返回空正文

agent 回信原话:「read_mail 返回的正文是空的,收件箱预览在「点击计数…」处被截断」
—— 它因此只看到前两条要求,写出来的页面漏了第 3 条(生成时间)。

根因在 `lib/inbox-format.js` 的渲染端:

    const body = m?.body_preview || m?.body || '';
    lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);

zcode 的 read_mail 用 `bodyLimit = 0` 表示"要全文"(HTTP 侧 `?body_limit=0`
也确实是这个语义,服务端返回了完整正文),但这里 `slice(0, 0)` 把正文渲染成
**空字符串** ⇒ 模型永远读不到全文,只能看收件箱里那段预览。

修法:`bodyLimit <= 0` 视为不截断;不截断时优先取 `body`(单封接口可能同时带
`body_preview`,那是短的那个)。四份副本逐字节同源(`check-shared-libs.sh`
通过),每个桥各加 2 条判据:0 = 不截断、不截断时优先全文。
扰动验证:退回旧写法 → 2 条红。

端到端验证:让 zcode 读全文并原样回报最后一行(一个随机标记)。
修复后它精确回出 `最后一行标记:ZTOKEN-2c7561fd` ✓ —— 修复前这不可能。

四家桥都已重新部署到新快照(pi/opencode/dsh/zcode),部署漂移检查:
「四个宿主都在跑当前代码」。测试基线:pi 417 / opencode 323 / dsh 372 / zcode 382。
2026-09-13 10:39:26 +08:00

313 lines
12 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, 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<Set<string> | 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<string>('');
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 (
<div className="w-full lg: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-3xs 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-3xs text-gray-500 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-mono text-gray-900 truncate">
{g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
</span>
<span className="ml-auto text-3xs text-gray-500 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-3xs 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-3xs">
<CheckIcon className="w-2.5 h-2.5" />
</span>
)}
{g.settled.length > 0 && (
<span className="text-3xs text-gray-500"> {g.settled.length}</span>
)}
</div>
{!g.alias && (
<p className="text-3xs text-gray-500 font-mono truncate mt-0.5 pl-5">
()
</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-3xs text-gray-500 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 || '');
// 待办是否已越过等待窗口:拿服务端给的**时刻**与本地当前时间比。
//
// 服务端刻意不发「是否失效」的布尔值 —— 那是发出那一刻的快照,经 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 (
<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'
: expired
// 失效的不该和还能立刻生效的长得一样:它点了也不会恢复原调用。
? 'border-gray-200 bg-gray-50 hover:bg-gray-100'
: '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-3xs text-gray-500 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-3xs ${
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>
) : expired ? (
<span
className="inline-flex items-center gap-0.5 text-3xs text-gray-500"
title={
'已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。' +
'现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'
}
>
<ShieldIcon className="w-2.5 h-2.5" />
</span>
) : (
<span className="inline-flex items-center gap-0.5 text-3xs text-orange-600 font-medium">
<ShieldIcon className="w-2.5 h-2.5" />
</span>
)}
</div>
</button>
);
}