用户报告:"我点到授权界面,才更新显示授权请求"。
先排除了推送本身:实测徽标是**实时**更新的(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。
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|