diff --git a/client/electron/src/api/client.ts b/client/electron/src/api/client.ts index bbf8f1c..8572c78 100644 --- a/client/electron/src/api/client.ts +++ b/client/electron/src/api/client.ts @@ -562,7 +562,15 @@ export async function archiveContact(payload: { address?: string; session_id?: s // ---------- Permission ---------- export async function decidePermission(mailId: string, decision: string, note?: string) { - return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', { + // expired/warning:请求已越过等待窗口时服务端会带上(见后端 decideResponse)。 + // 必须跟着返回类型走,否则界面又把「决策落到了一个没人在等的请求上」吞掉。 + return request<{ + status: string; + decision_mail_id: string; + expired?: boolean; + expires_at?: string; + warning?: string; + }>('POST', '/permission/decide', { mail_id: mailId, decision, note: note ?? '' diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index 6f9ec6e..5243b2d 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -607,13 +607,40 @@ export function PermissionPanel({ mail }: { mail: Mail }) { const [decided, setDecided] = useState(mail.permission_result || ''); // 问题模式下已勾选的选项(多选时是多个)。 const [picked, setPicked] = useState([]); + // 服务端判定这条决策越过了等待窗口时回给我们的说明。 + const [staleWarning, setStaleWarning] = useState(''); const fetchInbox = useMailStore(s => s.fetchInbox); const selectSession = useSessionStore(s => s.selectSession); + // 本地先算一遍「可能已失效」:服务端只给失效**时刻**,当前时间本地就有, + // 不必等一次往返。判定与后端 decideResponse 同一口径(严格晚于)。 + const expiredBeforeDecide = + !mail.permission_result && + !!mail.permission_expires_at && + Date.now() > Date.parse(mail.permission_expires_at); + + /** + * 越窗提示。 + * + * 两边都要显:决策**前**(人即将点下去)与决策**后**(人以为事情已经办了)。 + * 只在决策后显示等于让人先做错一次;只在决策前显示则补不上服务端在两次渲染 + * 之间越窗的情形。 + */ + const staleBanner = + staleWarning || expiredBeforeDecide ? ( +

+ {staleWarning || + '已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'} +

+ ) : null; + const submit = async (decision: string, noteText: string) => { setBusy(true); try { - await api.decidePermission(mail.mail_id, decision, noteText || undefined); + const res = await api.decidePermission(mail.mail_id, decision, noteText || undefined); + // 服务端在请求已越过等待窗口时会回 expired + warning:这次批准不会恢复 + // 当时那次工具调用。必须显示出来 —— 否则人看到「已处理」就以为事情办了。 + if (res?.warning) setStaleWarning(res.warning); setDecided(decision || '(自由文本回答)'); await fetchInbox('all'); if (mail.session_id) selectSession(mail.session_id); @@ -629,6 +656,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
已处理: {decided} + {staleBanner}
); } @@ -649,6 +677,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) { return (
+ {staleBanner}
{options.length === 0 ? '这题没有预设选项,请直接填写回答:' @@ -709,6 +738,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) { return (
+ {staleBanner}
{options.map(opt => (