diff --git a/client/electron/src/components/PermissionList.tsx b/client/electron/src/components/PermissionList.tsx index 46928d4..79c37a3 100644 --- a/client/electron/src/components/PermissionList.tsx +++ b/client/electron/src/components/PermissionList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMailStore } from '../stores/mailStore'; import { useSessionStore } from '../stores/sessionStore'; import { useUIStore } from '../stores/uiStore'; @@ -33,6 +33,35 @@ export default function PermissionList() { 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'); }, []); diff --git a/client/electron/test/components/PermissionList-autopen.test.tsx b/client/electron/test/components/PermissionList-autopen.test.tsx new file mode 100644 index 0000000..32d9d94 --- /dev/null +++ b/client/electron/test/components/PermissionList-autopen.test.tsx @@ -0,0 +1,98 @@ +/** + * 授权页:**新到手的待决请求必须自己露出来**。 + * + * 这一条来自线上实测的报告:"我点到授权界面,才更新显示授权请求"。 + * 复现出来的是:徽标数字实时变了(推送没问题),但新请求的正文看不见 —— + * 因为 `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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/plugins/dsh-mail-bridge/lib/inbox-format.js b/plugins/dsh-mail-bridge/lib/inbox-format.js index 8f2f54b..ba9331b 100644 --- a/plugins/dsh-mail-bridge/lib/inbox-format.js +++ b/plugins/dsh-mail-bridge/lib/inbox-format.js @@ -68,8 +68,18 @@ export function renderMail(m, bodyLimit = 200, selfName = '') { } // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 - const body = m?.body_preview || m?.body || ''; - lines.push(`内容: ${String(body).slice(0, bodyLimit)}`); + // bodyLimit <= 0 表示**不截断**(read_mail 用 0 要全文,HTTP 侧也是这个语义)。 + // + // 这一条曾经是线上故障:zcode 桥的 read_mail 传 0,而这里 `slice(0, 0)` 把正文 + // 渲染成**空字符串** —— 模型拿到的是"内容: ",于是它永远读不到全文,只能看 + // 收件箱里那段被截断的预览。实测:Agent 明确回信说「read_mail 返回的正文是空的, + // 收件箱预览在「点击计数…」处被截断」,并按残缺的要求做了活(漏掉第 3 条)。 + // + // 不截断时优先取 `body`:单封接口可能同时带两个字段,而 body_preview 是短的那个。 + const full = !(bodyLimit > 0); + const body = full ? m?.body || m?.body_preview || '' : m?.body_preview || m?.body || ''; + const bodyText = String(body); + lines.push(`内容: ${full ? bodyText : bodyText.slice(0, bodyLimit)}`); // 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。 // diff --git a/plugins/dsh-mail-bridge/test/inbox-format.test.mjs b/plugins/dsh-mail-bridge/test/inbox-format.test.mjs index c496a68..eaf54b0 100644 --- a/plugins/dsh-mail-bridge/test/inbox-format.test.mjs +++ b/plugins/dsh-mail-bridge/test/inbox-format.test.mjs @@ -104,6 +104,27 @@ test('正文按 bodyLimit 截断', () => { assert.equal(line.length, '内容: '.length + 50); }); +test('★ bodyLimit=0 表示不截断,且优先取全文(线上故障:read_mail 渲染成空)', () => { + // zcode 的 read_mail 就是传 0;曾经 slice(0, 0) → 正文成了空字符串 + const long = 'y'.repeat(500); + const line = renderMail(mail({ body: long }), 0).split('\n').find(l => l.startsWith('内容: ')); + assert.equal(line.length, '内容: '.length + 500); + // 反向对照:正数仍然截断 + assert.equal( + renderMail(mail({ body: long }), 10).split('\n').find(l => l.startsWith('内容: ')).length, + '内容: '.length + 10 + ); +}); + +test('★ 不截断时优先取 body 而不是 body_preview(过短的那个)', () => { + const both = mail({ body_preview: '预览很短', body: '全文' + 'z'.repeat(300) }); + const full = renderMail(both, 0); + assert.ok(full.includes('全文')); + assert.ok(!full.includes('预览很短')); + // 截断模式(收件箱列表)仍然优先预览 —— 那才是它的用途 + assert.match(renderMail(both, 5), /内容: 预览很短/); +}); + test('renderMail 容错:字段全缺不崩', () => { const got = renderMail({}); assert.match(got, /unknown/); diff --git a/plugins/opencode-mail-bridge/lib/inbox-format.js b/plugins/opencode-mail-bridge/lib/inbox-format.js index 8f2f54b..ba9331b 100644 --- a/plugins/opencode-mail-bridge/lib/inbox-format.js +++ b/plugins/opencode-mail-bridge/lib/inbox-format.js @@ -68,8 +68,18 @@ export function renderMail(m, bodyLimit = 200, selfName = '') { } // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 - const body = m?.body_preview || m?.body || ''; - lines.push(`内容: ${String(body).slice(0, bodyLimit)}`); + // bodyLimit <= 0 表示**不截断**(read_mail 用 0 要全文,HTTP 侧也是这个语义)。 + // + // 这一条曾经是线上故障:zcode 桥的 read_mail 传 0,而这里 `slice(0, 0)` 把正文 + // 渲染成**空字符串** —— 模型拿到的是"内容: ",于是它永远读不到全文,只能看 + // 收件箱里那段被截断的预览。实测:Agent 明确回信说「read_mail 返回的正文是空的, + // 收件箱预览在「点击计数…」处被截断」,并按残缺的要求做了活(漏掉第 3 条)。 + // + // 不截断时优先取 `body`:单封接口可能同时带两个字段,而 body_preview 是短的那个。 + const full = !(bodyLimit > 0); + const body = full ? m?.body || m?.body_preview || '' : m?.body_preview || m?.body || ''; + const bodyText = String(body); + lines.push(`内容: ${full ? bodyText : bodyText.slice(0, bodyLimit)}`); // 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。 // diff --git a/plugins/opencode-mail-bridge/test/inbox-format.test.mjs b/plugins/opencode-mail-bridge/test/inbox-format.test.mjs index c496a68..eaf54b0 100644 --- a/plugins/opencode-mail-bridge/test/inbox-format.test.mjs +++ b/plugins/opencode-mail-bridge/test/inbox-format.test.mjs @@ -104,6 +104,27 @@ test('正文按 bodyLimit 截断', () => { assert.equal(line.length, '内容: '.length + 50); }); +test('★ bodyLimit=0 表示不截断,且优先取全文(线上故障:read_mail 渲染成空)', () => { + // zcode 的 read_mail 就是传 0;曾经 slice(0, 0) → 正文成了空字符串 + const long = 'y'.repeat(500); + const line = renderMail(mail({ body: long }), 0).split('\n').find(l => l.startsWith('内容: ')); + assert.equal(line.length, '内容: '.length + 500); + // 反向对照:正数仍然截断 + assert.equal( + renderMail(mail({ body: long }), 10).split('\n').find(l => l.startsWith('内容: ')).length, + '内容: '.length + 10 + ); +}); + +test('★ 不截断时优先取 body 而不是 body_preview(过短的那个)', () => { + const both = mail({ body_preview: '预览很短', body: '全文' + 'z'.repeat(300) }); + const full = renderMail(both, 0); + assert.ok(full.includes('全文')); + assert.ok(!full.includes('预览很短')); + // 截断模式(收件箱列表)仍然优先预览 —— 那才是它的用途 + assert.match(renderMail(both, 5), /内容: 预览很短/); +}); + test('renderMail 容错:字段全缺不崩', () => { const got = renderMail({}); assert.match(got, /unknown/); diff --git a/plugins/pi-mail-bridge/lib/inbox-format.js b/plugins/pi-mail-bridge/lib/inbox-format.js index 8f2f54b..ba9331b 100644 --- a/plugins/pi-mail-bridge/lib/inbox-format.js +++ b/plugins/pi-mail-bridge/lib/inbox-format.js @@ -68,8 +68,18 @@ export function renderMail(m, bodyLimit = 200, selfName = '') { } // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 - const body = m?.body_preview || m?.body || ''; - lines.push(`内容: ${String(body).slice(0, bodyLimit)}`); + // bodyLimit <= 0 表示**不截断**(read_mail 用 0 要全文,HTTP 侧也是这个语义)。 + // + // 这一条曾经是线上故障:zcode 桥的 read_mail 传 0,而这里 `slice(0, 0)` 把正文 + // 渲染成**空字符串** —— 模型拿到的是"内容: ",于是它永远读不到全文,只能看 + // 收件箱里那段被截断的预览。实测:Agent 明确回信说「read_mail 返回的正文是空的, + // 收件箱预览在「点击计数…」处被截断」,并按残缺的要求做了活(漏掉第 3 条)。 + // + // 不截断时优先取 `body`:单封接口可能同时带两个字段,而 body_preview 是短的那个。 + const full = !(bodyLimit > 0); + const body = full ? m?.body || m?.body_preview || '' : m?.body_preview || m?.body || ''; + const bodyText = String(body); + lines.push(`内容: ${full ? bodyText : bodyText.slice(0, bodyLimit)}`); // 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。 // diff --git a/plugins/pi-mail-bridge/test/inbox-format.test.mjs b/plugins/pi-mail-bridge/test/inbox-format.test.mjs index c496a68..eaf54b0 100644 --- a/plugins/pi-mail-bridge/test/inbox-format.test.mjs +++ b/plugins/pi-mail-bridge/test/inbox-format.test.mjs @@ -104,6 +104,27 @@ test('正文按 bodyLimit 截断', () => { assert.equal(line.length, '内容: '.length + 50); }); +test('★ bodyLimit=0 表示不截断,且优先取全文(线上故障:read_mail 渲染成空)', () => { + // zcode 的 read_mail 就是传 0;曾经 slice(0, 0) → 正文成了空字符串 + const long = 'y'.repeat(500); + const line = renderMail(mail({ body: long }), 0).split('\n').find(l => l.startsWith('内容: ')); + assert.equal(line.length, '内容: '.length + 500); + // 反向对照:正数仍然截断 + assert.equal( + renderMail(mail({ body: long }), 10).split('\n').find(l => l.startsWith('内容: ')).length, + '内容: '.length + 10 + ); +}); + +test('★ 不截断时优先取 body 而不是 body_preview(过短的那个)', () => { + const both = mail({ body_preview: '预览很短', body: '全文' + 'z'.repeat(300) }); + const full = renderMail(both, 0); + assert.ok(full.includes('全文')); + assert.ok(!full.includes('预览很短')); + // 截断模式(收件箱列表)仍然优先预览 —— 那才是它的用途 + assert.match(renderMail(both, 5), /内容: 预览很短/); +}); + test('renderMail 容错:字段全缺不崩', () => { const got = renderMail({}); assert.match(got, /unknown/); diff --git a/plugins/zcode-mail-bridge/lib/inbox-format.js b/plugins/zcode-mail-bridge/lib/inbox-format.js index 8f2f54b..ba9331b 100644 --- a/plugins/zcode-mail-bridge/lib/inbox-format.js +++ b/plugins/zcode-mail-bridge/lib/inbox-format.js @@ -68,8 +68,18 @@ export function renderMail(m, bodyLimit = 200, selfName = '') { } // 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。 - const body = m?.body_preview || m?.body || ''; - lines.push(`内容: ${String(body).slice(0, bodyLimit)}`); + // bodyLimit <= 0 表示**不截断**(read_mail 用 0 要全文,HTTP 侧也是这个语义)。 + // + // 这一条曾经是线上故障:zcode 桥的 read_mail 传 0,而这里 `slice(0, 0)` 把正文 + // 渲染成**空字符串** —— 模型拿到的是"内容: ",于是它永远读不到全文,只能看 + // 收件箱里那段被截断的预览。实测:Agent 明确回信说「read_mail 返回的正文是空的, + // 收件箱预览在「点击计数…」处被截断」,并按残缺的要求做了活(漏掉第 3 条)。 + // + // 不截断时优先取 `body`:单封接口可能同时带两个字段,而 body_preview 是短的那个。 + const full = !(bodyLimit > 0); + const body = full ? m?.body || m?.body_preview || '' : m?.body_preview || m?.body || ''; + const bodyText = String(body); + lines.push(`内容: ${full ? bodyText : bodyText.slice(0, bodyLimit)}`); // 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。 // diff --git a/plugins/zcode-mail-bridge/test/inbox-format.test.mjs b/plugins/zcode-mail-bridge/test/inbox-format.test.mjs index c496a68..eaf54b0 100644 --- a/plugins/zcode-mail-bridge/test/inbox-format.test.mjs +++ b/plugins/zcode-mail-bridge/test/inbox-format.test.mjs @@ -104,6 +104,27 @@ test('正文按 bodyLimit 截断', () => { assert.equal(line.length, '内容: '.length + 50); }); +test('★ bodyLimit=0 表示不截断,且优先取全文(线上故障:read_mail 渲染成空)', () => { + // zcode 的 read_mail 就是传 0;曾经 slice(0, 0) → 正文成了空字符串 + const long = 'y'.repeat(500); + const line = renderMail(mail({ body: long }), 0).split('\n').find(l => l.startsWith('内容: ')); + assert.equal(line.length, '内容: '.length + 500); + // 反向对照:正数仍然截断 + assert.equal( + renderMail(mail({ body: long }), 10).split('\n').find(l => l.startsWith('内容: ')).length, + '内容: '.length + 10 + ); +}); + +test('★ 不截断时优先取 body 而不是 body_preview(过短的那个)', () => { + const both = mail({ body_preview: '预览很短', body: '全文' + 'z'.repeat(300) }); + const full = renderMail(both, 0); + assert.ok(full.includes('全文')); + assert.ok(!full.includes('预览很短')); + // 截断模式(收件箱列表)仍然优先预览 —— 那才是它的用途 + assert.match(renderMail(both, 5), /内容: 预览很短/); +}); + test('renderMail 容错:字段全缺不崩', () => { const got = renderMail({}); assert.match(got, /unknown/);