Commit Graph

7 Commits

Author SHA1 Message Date
47fe9a5e74 test: session-scan 内存泄漏测试 + ui-sweep 界面验收脚本 2026-09-06 15:18:30 +08:00
55b3f9bc4e fix(web): 地址显示按「人 / Agent」分维度 —— 别名跟 Agent 走,人只显示名字
## 症状

单封邮件的元信息三行都不对(生产实测那封 12:12:12):

    发件  jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
    收件  pi@/home/program/agentmail
    抄送  pi@/home/program/agentmail.new

人指定的是「投进 pi 的那条会话」,而界面把会话别名拼给了**发件人**。

## 三处错

**1. 别名拼错了一方。** `name@path.session` 三段才唯一确定「哪个 Agent、
在哪个目录、哪条线索」—— 别名必须跟 Agent 走。拼给发件人之后收件人变成
`pi@/home/program/agentmail`,那指向**默认会话**而不是人指定的那条。

**2. 人不该有目录和会话位。** 人没有工作目录,发给人就是进收件箱。
`jianf.某会话` 是把 Agent 的三维语义硬套在人身上,而且因为 from_workspace
为空,拼出来的形态连 ParseAddress 都还原不了 —— 没有 `@` 时整串被当成
**名字**(实测 name="jianf.某会话别名"),投递必然 404。

**3. 抄送残留 `.new`。** 它是一次性动作,建完会话就失效;留着会让人以为
再发一次还能投进同一条会话,实际会开出第三条。

## 修法

`identityAddress` → `participantAddress(name, workspace, alias)`,
判据是有没有 workspace:

    Agent → pi@/home/program/agentmail.日程提醒:…    三段齐全
    人    → jianf                                     裸名字

`ccAddress` 按同一判据分流;`.new` 换成当前会话别名。
六处手工拼接(MailView / MailList ×2 / ThreadView)统一走这两个函数。

## 顺带修掉 `dsh@dsh`

改的时候实测发现:**`mails.from_workspace` 对 Agent 存的是 Agent 名而不是
路径**(历史遗留,见 db/migrate.go 里 sessions.workspace 的注释)。
拿它当路径拼,Agent 发来的信显示成 `dsh@dsh`。

会话的 workspace 才是权威来源 → `models.Mail` 新增 `SessionWorkspace`,
六处查询补 `s.workspace`:GetMailByID / ListInbox / GetSessionMails /
GetSessionMailByID / ListSentBy / threadCols。

## formatAddress 与后端对齐

第一版我改成「path 为空时舍弃 session 返回裸名字」,对着后端 ParseAddress
跑了一遍才发现搞反了 —— **正确形态是保留 `@`**:

    jianf@.任务  → name=jianf path="" session=任务   ✓
    jianf.任务   → name="jianf.任务"                  ✗

现在两端六个 case 逐例一致(这个分支只在内部逻辑上用得到;
展示一律走 participantAddress,人根本不带会话位)。

## 取舍

列表行与对话树节点**不带会话位**:列表的分组头已单独显示别名,
树的每个节点都在同一条线索上 —— 重复无信息量,而 92 字节的别名会把那行挤没。

## homeagent 日程工具的两个修复(同批)

**查询串手拼吃掉了时区。** RFC3339 的 `+08:00` 里那个 `+` 在查询串里正是
空格的转义形式,服务端 ParseQuery 还原成空格 → time.Parse 失败 →
AgentListCalendarEvents **静默退回默认区间**(不报错)。表现为「明明有日程
却说一条都没有」。改走 url.Values.Encode()。

**默认窗口 3 个月太窄。** yearly / lunar_yearly 的下一次触发随时落在窗口外,
模型问「我建过什么」得到空结果,然后照着空结果再建一条重复的。改成 14 个月。
空结果的话术也从「你还没有建过日程」改成说出实际查询区间 —— 前者在窗口外
有事件时是假话。

## 验收

- web 182 例(replyTarget 24 → 46);tsc 无错;Gateway 7 包全过
- 新增 test/manual/addr-verify.mjs:真渲染两个方向都验过
    人 → Agent:jianf / pi@/home/program/agentmail.日程提醒:…
    Agent → 人:dsh@/home/program/agentmail.查看工程与插件适配指南 / jianf
  判据含「Agent 的 path 必须是真路径而不是 Agent 名」(锁 dsh@dsh 那个 bug)
2026-09-04 13:49:16 +08:00
390fef8941 fix(web): 补齐强调色的 CSS 变量 —— 红/绿/橙/黄按钮此前不可见但可点
## 症状

所有界面的「确认」类按钮看不见,但对应位置点击照样生效。
归档确认、删除、危险操作、状态徽标全部受影响;蓝色主按钮正常。

## 根因

`tailwind.config.js` 的 `colors` 里对 red/green/amber/orange/yellow
**同时写了两份定义**:先是固定 hex,紧接着又是 `accent('red')`。
JS 对象字面量重复键**后者胜出**,不报错、不警告 —— 读代码的人看到上面那份
hex 以为在用它,实际生效的是下面那份变量引用。

而 `index.css` 里当时只有 20 个变量(white / on-accent / gray / chrome),
没有任何 `--c-red-*`。CSS 里变量未定义会让**整条声明失效**:

    .bg-red-600 { background-color: rgb(var(--c-red-600) / 1) }   ← 整条被丢弃

于是 `bg-red-600` 退回透明,而 `text-white`(走 `--c-on-accent`,浅色下是纯白)
照常生效 → 白字落在白卡片上。按钮的盒子、padding、点击区域全都在。

实测部署产物里 32 个变量被引用但从未定义。blue 逃过一劫只因为它没有第二份
`accent('blue')` 定义,编译成了固定值。

## 修法(用户选 B:补齐变量,让强调色也参与主题)

`index.css` 新增 96 个变量,`tailwind.config.js` 去掉重复定义。

**强调色是两段语义色阶,深色下走向相反**:
- `50`–`300` = 表面(chip 底、提示条底、边框)→ 深色下**变暗**。
  照搬浅色值的话 red-50 (#fef2f2) 在深色页面上是一块近白亮斑 ——
  那是错误提示条的底,结果比正文还抢眼,上面的红字反而读不动。
- `400`–`900` = 前景(文字、图标)→ 深色下**变亮**。
  照搬时 red-700 只有 2.67:1、amber-900 只有 1.90:1。现在每档 ≥4.5
  (最低 red-400 = 5.93)。

**实心按钮底另立一组 `--s-*`,两种模式同值。**
那六档在深色下被提亮是为了 `text-red-600` 读得动,而 `bg-red-600 text-white`
的白字落在提亮后的浅红上只有 1.6:1。一个名字服务两种语义必然坏掉一头 ——
与此前 text-white/bg-white 那次同理。只覆盖 `backgroundColor`,
`text-*`/`border-*`/`ring-*` 仍走 `accent()`。

顺带把浅色 red-600 从官方的 220 38 38 压到 213 37 37:官方值落在 red-50 上
只有 4.41:1,而 `bg-red-50 text-red-600` 正是错误提示条。

## 防复发

`test/theme.test.mjs` 20 → 26 条,新增 6 条针对这次的:
- **Tailwind 实际使用的每个变量都在 index.css 有定义**。判据走 resolveConfig
  而不是正则扫配置文本:出问题的变量名是 `accent('red')` 模板拼出来的,
  源码里没有 `--c-red-600` 这个字面量,扫文本会漏掉正是要防的那一类
- colors 里没有重复的颜色名(这次 bug 的成因)
- 表面段深色下变暗 / 前景段在深色卡片上 ≥4.5:1(逐档断言,72 项)
- 实心底走 `--s-*` 且未被 `.dark` 覆盖
- 白字在实心底上 ≥3:1

新增 `test/manual/accent-verify.mjs`:真浏览器渲染 17 组配色 × 两模式,
读 `getComputedStyle` 量实际值,**把「背景透明」单独判为失败**(那正是本次
bug 的指纹)。只以 `hover:` 变体出现的档不能放进探针 —— Tailwind 不生成
未使用的基础类,探它必然透明,是假阳性。

## 验收

- theme.test.mjs 26 条全过;web 160 例;tsc 无错
- accent-verify 两模式各 17 项全过(浅色最低 4.51、深色最低 3.05)
- theme-verify 9 项全过;wide-regression 5 项全过
- 截图逐像素核对:浅色侧栏 (15,23,42) / 卡片 (255,255,255) / 页面底 (249,250,251);
  深色 (12,14,18) / (24,27,33) / (17,19,24) —— 层次关系两模式一致
2026-09-04 11:57:42 +08:00
d74f356f41 feat(web): 深色主题 —— 反转灰阶而非逐处 dark: 前缀
**逐处加 dark: 前缀的方案在这里必然失败**:约 700 处颜色散在 21 个组件里,
漏一处就是深色下的白底白字,而它不报错、不影响构建、只有肉眼能发现,
且往往只出现在某个不常开的页面。此后每加一个组件都要记得写两遍,
那种约定活不过三次改动。

改法是把颜色下沉到 CSS 变量,深色模式**反转灰阶**。这套代码的灰阶本身
就是语义色阶(white/gray-50 = 表面层次,gray-200/300 = 分隔线,
gray-900→400 = 文字主次),反转之后 `bg-white text-gray-900` 自动变成
深色卡片 + 浅色文字。零组件改动,新组件照常写浅色类名也自动适配。

变量存 **RGB 三元组**而非 #hex:代码里有 bg-blue-50/70 这类透明度修饰符,
Tailwind 生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS ——
那些半透明高亮会静默失效(不报错,只是不透明)。

---

实测撞了三个必须分离的语义,每一个共用变量就坏:

**1. text-white 不能跟 bg-white 走。**
`white` 服务两种冲突用途:卡片表面(深色下要变暗)与彩色按钮上的文字
(深色下必须保持浅色)。共用时后者跟着变暗 —— 激活导航项的「收件」在
bg-chrome-700 上只剩 **1.34:1**,几乎消失。拆出 --c-on-accent。

**2. 侧栏与底部导航不能跟 gray 走。**
它们在浅色模式下**本来就是深色的**(深色侧栏配浅色内容区是原本设计)。
并入反转灰阶后深色模式下变成近白色(实测 rgb(243,245,248)),比内容区
(rgb(17,19,24))还亮,整个层次翻过来。独立成 chrome 色阶,深色下只微调、
保持「框架比内容更沉」。

**3. 强调色不能反转。**
blue/red 跟着变会让主按钮在深色页面上失去「这是主操作」的视觉重量,
而且白字落在变暗的 blue-600 上对比度掉到 3:1 以下。改成固定值。

---

顺带修的三处真实对比度不足(实测量出来的,不是猜的):
- 待决策橙徽标:orange-500 上白字 2.80:1 → orange-700 5.18:1
  (保留橙色语义,不能改成灰 —— 它与未读的红色是两种紧急)
- 空状态文案:gray-400 2.43:1 → gray-500。这类文字是**页面上唯一的内容**,
  不是次要装饰,读不动等于页面空白
- 列表头计数:同上

---

主题是**三态**而非开关:system 不是 light 的别名 —— 只给开关的话,
白天设浅色之后晚上系统切深色应用不会跟着变。且只有 pref 为 system 时
才跟随系统,显式选了的人不该因为日落被切换。

index.html 加同步内联脚本消除首帧闪屏:bundle 有 430KB,从 HTML 解析完到
React 挂载之间页面是 body 默认色,深色用户每次刷新都被闪一下白屏。
外链或 defer 都晚于首次绘制。它与 themeStore 共用同一个 localStorage 键
(不一致会导致首帧按 A 键渲染、挂载后按 B 键重渲染,闪一下再变回去)。

body 显式设底色:移动端橡皮筋回弹露出的是 body 背景。

入口两处:侧栏单按钮快速翻转,「我的」页三选一设定偏好。单按钮不足以
表达三态,但只给单按钮的话用户一旦点过就永久脱离「跟随系统」——
那是个回不去的单向门。

测试:test/theme.test.mjs 20 条结构性断言(已进 npm test),
test/manual/theme-verify.mjs 真实渲染对比度验收(遍历可见文本节点算 WCAG
比值,往上找第一个不透明背景)。两种模式各 4 项全过。
2026-09-04 06:30:26 +08:00
e504eccf3a fix(web): 授权独立成导航项 + 收件箱按会话分组 + 回复对端判定
**授权请求不是「一封信」而是「一件待办」。**

生产取证:一个 pi 会话独占 17 封权限邮件(后涨到 39),另外两个会话各
3 封 / 1 封 —— 收件箱被一件事塞满,失去了它唯一的作用(让人知道有哪几件
事在等我)。

第一版做成会话折叠,用户纠正后重做:权限请求的生命周期是「等人点头 →
决策完就作废」,与普通邮件混在一个箱子里两者互相伤害。收件箱只放要读的,
授权项只放要批的。

`groupPermissions` 的排序判据是「要不要我动手」而非时间:三天前发起、
至今还卡着的授权比十分钟前刚批完的重要得多。纯按时间排会把它压到底部,
而 Agent 那条会话正在等 —— 那正是权限死锁在 UI 上的样子。

未读徽标用红色、待决策用橙色,且两个数字**互不重复计数**:未读是
「有内容没看」,待决策是「有 Agent 卡着等我」。

---

**回复发给自己的 bug(用户报)。**

根因:ReplyBar 的对端判定写死 `from_name === 'human'` —— 单用户时代遗留
(当时人类只有 human@ 一个身份)。登录名是 jianf 时判据恒为假,
于是取 from_name(自己)。

生产链条:`27c22900 jianf→pi` 对(锚点是 pi 发来的),
`8e519925 jianf→jianf` 错(锚点是自己发的)。

两处修正:
1. **判据必须是当前登录用户名**,不是字面量 'human'。同一遗留判据在三处:
   对端解析 / replyAll 去自己 / ThreadCard 图标。
2. **会话视图的回复对端是会话的属性,不看任何单封邮件。**
   人在那儿打字就是「给这次任务的对方追加一句」;用「最后一封」当锚点时,
   自己刚发过信就会把自己算成对端。sessionCounterpart 扫全会话取首个
   非我参与方(收件人优先于发件人,同刻用 mail_id 定序)。

ThreadCard 顺带显示真实发件人名而不是统一渲染成 'human' —— 会话里可能有
多个人类参与方。

测试:mailGroups 30 例(含「生产实测形状:17 封权限邮件」)、
replyTarget 24 例(含生产链条重现:断言 target 以 pi@ 开头而非 jianf@)。
手工验收脚本 inbox-group-verify.mjs 六项全过。
2026-09-04 06:29:21 +08:00
514f443e54 fix: 「我的」页无法滚动 —— 缺滚动容器;顺带修矮屏登录页
用户反馈「我的页面点击进入后无法滑动」。

## 根因:AccountPage 根本没有滚动容器

窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
`flex-1 min-w-0 flex flex-col`,中间缺一层 `overflow-y-auto` ——
内容超出的部分**直接被裁**,滚不到也点不到。

实测 390px 下内容(资料 + 权限 + 改密码 + 密钥 + 退出)需 860px、容器只有
795px,「退出登录」按钮连同下面 65px 一起消失;1280x800 的桌面上同样看不到。

其余六个页面级组件(AdminUsersPage / MailView / ComposePage / ThreadView /
ContactPanel / MailList)都有这一层,只有它漏了 —— 上一轮把退出登录搬进这页
之后内容变高,问题才显形。

## 顺带:登录页与初始化页在矮屏滚不到底

卡片高约 371px,而 `h-full flex items-center` 在内容超高时让它上下**同时**溢出,
溢出到顶部那段是滚不到的(`scrollTop` 最小是 0)—— 实测 568x280(横屏手机,
或软键盘弹出后的可视高度)下「登录」按钮完全在视口外,光加 `overflow-y-auto`
也够不着。

改用卡片自己的 `my-auto` 而不是容器的 `items-center`:auto margin 在空间不足时
自动退化为 0,矮屏变成正常的顶对齐可滚布局,高屏仍然垂直居中
(390x844 与 1280x800 实测 centered=true,568x280 下滚到底能看到按钮)。

## 两侧都加了检查

- 结构断言:七个页面级组件都必须含 `overflow-y-auto`;
  登录/初始化页必须有 `my-auto` 且不用 `h-full ... items-center`
- 实测脚本新增 `scrollHealth()`:每页都有滚动容器,且没有内容被
  `overflow-hidden` 的父级裁掉

判据刻意是「**有**滚动容器」而不是「当前正在滚动」—— 内容暂时不够高时后者
为假,但页面是健康的;真正的 bug 是根本没有那一层。

## 验证

窄屏实测 18 项 + 宽屏回归 5 项全通过;结构断言从 28 条扩到 37 条。
生产已部署。
2026-09-03 07:39:52 +08:00
342282b92c fix: 窄屏实测修复 —— 删抽屉、44px 命中区、触摸设备可见的次要动作
用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px
(iPhone SE)量真实盒子。之前的窄屏适配是「照着规则写对」,实测发现
一个功能性 bug 加五处可用性问题。

## 抽屉式侧栏遮挡底部导航(真 bug)

抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有
z-index。抽屉打开时点最左那一项「收件」,elementFromPoint 命中的是抽屉里的
SVG,不是导航按钮 —— 按钮在那里、尺寸也够、CSS 规则也没写错,只有命中测试
才能发现。

**删掉抽屉而不是给导航加 z-index**:抽屉装的六项(收件/发件/联系/用户/新建/
管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 fixed
层级加遮罩不划算,而它还附带「Esc 关不掉」「底层未锁滚」两个毛病。

退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那页此前根本
没有退出入口。`NavToggle` 与 uiStore 的 navOpen/toggleNav/closeNav 一并删除。

## 触摸命中区:新增 .tap

详情页那排工具按钮视觉高度只有 15-16px(实测「标记已读」48x16、「对话树」
54x16、「转发」42x16、「抄送」20x15),移动端下限是 44x44。

直接加 padding 会把本来就挤的头部撑散、320px 下换行,因此用居中的透明伪元素
扩大命中区:**视觉一像素不动**。只在 max-width:767px 生效 —— 桌面用鼠标精度
足够,而扩大后的命中区在密排工具栏里会互相重叠,点一个可能命中隔壁那个。

覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage /
ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。

## 看不见却按得动的按钮:新增 .reveal

`opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 opacity:0,
**但仍然接收点击** —— 实测联系人列表里 elementFromPoint 命中的就是那个看不见的
「归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟:人以为点的是卡片,
实际归档了一条会话。

改为默认可见,只在 `(hover: hover) and (pointer: fine)` 时隐藏。
单看 hover 会把带触摸板的平板算进去。

## 对话树

- 缩进随屏宽自适应:固定「每级 20px、上限 8 级」= 最多 160px,320px 屏还要
  去掉 px-4 的 32px 与连接线 18px,卡片只剩 110px,发件人一行直接被 truncate
  吃掉。窄屏改为每级 10px、上限 5 级
- 补返回出口:原先只有「关闭」。两者语义不同 —— 返回退出整个详情栏回到列表,
  关闭只收起树、留在这封邮件上

## 把实测脚本留进仓库

`web/test/manual/`(`npm run test:narrow` / `test:wide`),不进 npm test ——
要一个跑着的浏览器加一个活的 Gateway。

留着而不是用完即删,是因为结构性断言守不住「按钮实际多大、点下去命中谁」,
而这次最严重的 bug 恰好只有 elementFromPoint 能发现。helper 里两个函数专门
为此:tapTargets() 量 .tap 的真实命中区(伪元素尺寸,不是 boundingBox),
hitTest() 验每个元素点下去是否命中自己。

## 验证

- 窄屏 13 项 + 宽屏 5 项全通过。宽屏回归特意验了两件只该在窄屏生效的事:
  .tap 伪元素 content 为 none、没有返回按钮
- narrow-layout.test.mjs 从 20 条扩到 28 条,逐条钉住上面每个修复
- 无横向溢出:390px 与 320px 下 scrollWidth === clientWidth
- 生产已部署
2026-09-02 23:53:59 +08:00