Files
MailUI4Agents/client/electron/test/components/PermissionList-autopen.test.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

99 lines
4.7 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.

/**
* 授权页:**新到手的待决请求必须自己露出来**。
*
* 这一条来自线上实测的报告:"我点到授权界面,才更新显示授权请求"。
* 复现出来的是:徽标数字实时变了(推送没问题),但新请求的正文看不见 ——
* 因为 `expanded` 一旦被手动点过就冻结成快照,之后新出现的待决会话
* 一直处于折叠状态,直到离开再回来(组件重挂载、默认展开重算)。
*
* 两条判据必须成对:新请求要自动展开,而用户在那之后的手动折叠**不许**被弹开。
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PermissionList from '../../src/components/PermissionList';
import { useMailStore } from '../../src/stores/mailStore';
import { useSessionStore } from '../../src/stores/sessionStore';
import { useUIStore } from '../../src/stores/uiStore';
const perm = (sessionId: string, subject: string, id: string) => ({
mail_id: id,
session_id: sessionId,
from_name: 'dsh',
to_name: 'gui-lab',
subject,
body: '工具bash',
mail_type: 'permission_request' as const,
permission_options: ['同意', '拒绝'],
permission_multi_select: false,
permission_result: '',
status: 'unread',
created_at: '2026-09-13T01:00:00Z',
session_alias: 'sess-' + sessionId,
session_workspace: '/tmp/' + sessionId
});
describe('授权页的自动展开', () => {
beforeEach(() => {
useMailStore.setState({ inbox: [], currentMail: null, accountErrors: [] } as never);
useSessionStore.setState({ current: null } as never);
useUIStore.setState({ viewMode: 'permissions' });
});
it('★ 新到手的待决请求要自动展开(不能等重进页面)', async () => {
// 会话 A 已有待决 → 自动展开
useMailStore.setState({ inbox: [perm('sess-a', 'A 的旧请求', 'm-a1')] } as never);
render(<PermissionList />);
expect(await screen.findByText('A 的旧请求')).toBeInTheDocument();
// 用户手动折叠一次 —— 这一步把 expanded 冻结成快照(线上就是这样)
// 点会话分组头:按内容找按钮(别名与时间连在同一串文本里,
// 用 getByText 会命中不到;/待决策/ 又会同时命中顶部计数)
const headerA = screen.getAllByRole('button').find(b => (b.textContent || '').includes('sess-sess-a'));
expect(headerA).toBeTruthy();
await userEvent.click(headerA!);
await waitFor(() => expect(screen.queryByText('A 的旧请求')).toBeNull());
// 新请求到达(另一个会话,此前没有待决)
useMailStore.setState({
inbox: [perm('sess-a', 'A 的旧请求', 'm-a1'), perm('sess-b', 'B 的新请求', 'm-b1')]
} as never);
// ★ 必须自己露出来;同时 A 保持用户折叠的状态(不能被弹开)
expect(await screen.findByText('B 的新请求')).toBeInTheDocument();
expect(screen.queryByText('A 的旧请求')).toBeNull();
});
it('★ 同一个已折叠的会话里又来一条新待决 → 也要露出来(实测撞到的就是这种)', async () => {
useMailStore.setState({ inbox: [perm('sess-a', 'A 的旧请求', 'm-a1')] } as never);
render(<PermissionList />);
expect(await screen.findByText('A 的旧请求')).toBeInTheDocument();
const hdr = screen.getAllByRole('button').find(b => (b.textContent || '').includes('sess-sess-a'));
await userEvent.click(hdr!);
await waitFor(() => expect(screen.queryByText('A 的旧请求')).toBeNull());
// 同一会话又来一条(会话早就在折叠状态里)—— 必须自己打开
useMailStore.setState({
inbox: [perm('sess-a', 'A 的旧请求', 'm-a1'), perm('sess-a', 'A 的新一条', 'm-a2')]
} as never);
expect(await screen.findByText('A 的新一条')).toBeInTheDocument();
});
it('反向对照:没有新邮件时,手动折叠的分组保持折叠(尊重人的操作)', async () => {
useMailStore.setState({ inbox: [perm('sess-a', 'A 的旧请求', 'm-a1')] } as never);
render(<PermissionList />);
expect(await screen.findByText('A 的旧请求')).toBeInTheDocument();
const hdr = screen.getAllByRole('button').find(b => (b.textContent || '').includes('sess-sess-a'));
await userEvent.click(hdr!);
await waitFor(() => expect(screen.queryByText('A 的旧请求')).toBeNull());
// 只重渲染、没有新邮件 → 不该被弹开
useMailStore.setState({ inbox: [perm('sess-a', 'A 的旧请求', 'm-a1')] } as never);
await new Promise(r => setTimeout(r, 80));
expect(screen.queryByText('A 的旧请求')).toBeNull();
});
});