From 1f517eca4b139ffae0f556882255a451cc4120e9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 09:09:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui)=E2=98=85=E2=98=85:=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A0=91=E6=94=B9=E6=88=90=E7=94=BB=E5=B8=83=E5=BC=8F?= =?UTF-8?q?=E7=9C=9F=E6=A0=91=20+=20=E4=BF=AE=E4=B8=A4=E4=B8=AA=E7=BA=BF?= =?UTF-8?q?=E4=B8=8A=20bug=EF=BC=9B=E6=97=A5=E5=8E=86=E7=BF=BB=E6=9C=88?= =?UTF-8?q?=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移植自本地 main(含 3536689..e670f6f 的最终状态)。 基于 origin/main 而非 rebase 本地 —— 本地那条链上的 6 个提交 (0a9287b/d52be3f/74aacc3/646cb3f/6e198d2/010c8ec)是远端已推内容的等价改写, 重复移植只会制造冲突;远端另有一条线上的成果(main.cjs/preload.cjs/ index.css/main.tsx/TitleBar.tsx/run-all.mjs 与 4 份 docs)本地没有, **不能覆盖**,故从 origin/main 起只取本次真正改动的 5 个文件。 ## 对话树:换成画布式真树 用户连续三轮反馈「这不还是老版的对话树」,最后明确要求: 「直接用你之前写的那个**可拖动缩放的对话树界面**替换现在这个对话树界面」。 我前两轮改错了方向 —— 一直在**缩进列表**上打补丁(改排序 → 改竖线 → 整份重写),而缩进列表**表达不了分支**:实测最深的线索 205 封、59 层, 多个兄弟追问并排时缩进线断成一片;画布上每条边都是独立的一条线。 现在:可拖动平移 / 滚轮缩放(20%–250%,锚定光标)/ 点节点看正文 / 分层 Reingold–Tilford 布局(不用力导向:位置会变、点不准)。 ## 两个线上 bug(用户实测报告,我第一轮没看出来) ① **页面没撑满**:打开后 zoom=140%、pan.x=-9393,内容包围盒 11340×7193, 屏幕上只看得到左上角。而手动点「适应」是对的(0.1217)—— 同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。 逐层实测揪出三个连环坑:`nodes` 是**根数组**(只 1 个根)、 自动 fit 的 effect 没在数据到达后重跑、`fittedFor` 守卫置位太早把自己挡掉。 另:`MailView` 的 `threadOf` 分支裸返回 ``,缺 `flex-1 min-w-0 min-h-0` ⇒ 高度链断。 ② **一缩放就崩**:`onWheel` 直接解引用 `e.currentTarget`, 组件卸载后滚轮事件仍在飞 ⇒ `TypeError: Cannot read properties of null`。 实测放大 20 次后面板消失。改成先取可空引用 + 空值守卫。 真机复测:200 节点 / 55 层,打开即 fit 到 12%,放大 20 次 + 缩小 25 次 全部 0 崩溃 0 错误。 ## 日历:翻月卡死 用户实测「点击切换月份的按钮,直接从10月跳到八月卡死」。 CDP 复现: 第1跳起:opacity="0",transform="translateX(-94.8px)",元素 x = -15 右侧工具栏(导入/导出/新建)整块消失 根因:注释写着「用**声明式** key + 类」,实现却是两个 ref —— **ref 改动不触发重渲染** ⇒ slideClass 只能用上一次的方向,且 class 名永不变 ⇒ 动画只播一次不再重放。而 `animation-fill-mode: both` 把元素留在帧之间, 动画一旦没跑到终点就永久停在 `translateX(-12%) + opacity:0`。 修:ref → `useState<{dir,seq}>`;`seq` 进 key 让动画真重放; 加 260ms 自清理兜底(最坏只是没动画,不会卡死)。 复测:12 次连点,每次都是「class 已摘掉 OK」,侧栏/网格/工具栏全好。 ## 判据 `thread-view-tree.test.mjs` 15 格 + `calendar-slide.test.mjs` 6 格, 每个 bug 都有对应的**可复现变异**(退回 ref / 去掉清理 / 去掉 seq …), 实测全部转红。 ## 教训 两轮都只跑 `tsc` + 判据就宣布完成,然后请用户去看 —— **「类型没错」不等于「页面能用」**。日历那一轮我更是盲巡检了十几轮 (还把「看起来被切」误判成 bug,最后自己量出 scrollHeight==clientHeight 才撤回)。 **先问清楚,比继续猜便宜得多。** --- .../electron/src/components/CalendarView.tsx | 47 +- client/electron/src/components/MailView.tsx | 13 +- client/electron/src/components/ThreadView.tsx | 664 +++++++++++------- client/electron/test/calendar-slide.test.mjs | 62 ++ .../electron/test/thread-view-tree.test.mjs | 201 ++++++ 5 files changed, 710 insertions(+), 277 deletions(-) create mode 100644 client/electron/test/calendar-slide.test.mjs create mode 100644 client/electron/test/thread-view-tree.test.mjs diff --git a/client/electron/src/components/CalendarView.tsx b/client/electron/src/components/CalendarView.tsx index ae08613..4f97d37 100644 --- a/client/electron/src/components/CalendarView.tsx +++ b/client/electron/src/components/CalendarView.tsx @@ -135,22 +135,47 @@ export default function CalendarView() { * 首次进入不动画(`navigated` 为 false),否则一打开日历就滑一下很怪。 */ const bodyRef = useRef(null); - const slideDir = useRef<1 | -1>(1); - const navigated = useRef(false); - const slideClass = navigated.current - ? slideDir.current === 1 - ? 'cal-slide-next' - : 'cal-slide-prev' - : ''; + /* + * ★ 必须用 **state**,不能用 ref(用户 2026-10-04 实测报告: + * 「点击切换月份的按钮,直接从10月跳到八月卡死」)。 + * + * 旧代码是 `slideDir` / `navigated` 两个 ref。注释写着「用**声明式** key + 类」, + * 实现却是命令式 ref —— 两者自相矛盾: + * + * ref 改动**不触发重渲染** ⇒ `slideClass` 只能用「上一次的」方向算出来, + * 且 class 名永远不变 ⇒ CSS 动画只播一次就不再重放。 + * 快速连点时元素被 `animation-fill-mode: both` 留在**起始帧**: + * animation-play-state: running,opacity: 0,translateX: -94.8px + * 实测元素 x = -15 ⇒ 整个日历滑出视口 ⇒ 观感就是「卡死」。 + * + * 顺带修掉方向错乱:连点「上一月」时 class 可能是 cal-slide-next。 + */ + const [slide, setSlide] = useState<{ dir: 1 | -1; seq: number } | null>(null); + const slideClass = slide === null ? '' : slide.dir === 1 ? 'cal-slide-next' : 'cal-slide-prev'; + + /* + * 动画结束后把 slide 清空 ⇒ class 被摘掉,元素回到自然位置。 + * + * ★ 为什么要这一步:`animation-fill-mode: both` 会把元素**留在** from/to 帧之间, + * 一旦动画因为任何原因(连点、标签页切后台、prefers-reduced-motion 切换) + * 没跑到终点,元素就永久停在 translateX(-12%) + opacity:0 上 —— + * 实测这正是用户说的「跳到八月卡死」(元素 x=-15,跑出视口)。 + * 定时清掉 class ⇒ 最坏情况只是没动画,不会卡死。 + */ + useEffect(() => { + if (slide === null) return; + const h = setTimeout(() => setSlide(null), 260); + return () => clearTimeout(h); + }, [slide]); function shift(dir: 1 | -1) { // 记下方向:过渡动画要往正确的方向推(下一段从右边进、上一段从左边进)。 // 手势和"上一页/下一页"按钮都走这里 ⇒ 方向只有一个来源。 - slideDir.current = dir; - navigated.current = true; + // ★ seq 递增:让 key 变化 ⇒ 每次翻页都真的重放一次动画。 if (scale === 'month') setAnchor(a => addMonths(a, dir)); else if (scale === 'week') setAnchor(a => addDays(a, dir * 7)); else setAnchor(a => addDays(a, dir)); + setSlide(s => ({ dir, seq: (s?.seq ?? 0) + 1 })); } /* @@ -412,7 +437,9 @@ export default function CalendarView() { // key 里带 anchor/scale:一变就是新元素 ⇒ 动画必然重放(见上面的说明)。
diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index 72801a0..2a12571 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -129,9 +129,16 @@ export default function MailView() { ); } - if (threadOf) { - return setThreadOf(null)} />; - } +if (threadOf) { + // ★ 必须带 flex-1 min-w-0 min-h-0:ThreadView 是裸返回, + // 不像下面的分支那样有这组类。没有它 ⇒ 面板不撑满、内容区高度塌陷 + // (用户 2026-10-04:「页面没有撑满」)。 + return ( +
+ setThreadOf(null)} /> +
+ ); + } return (
diff --git a/client/electron/src/components/ThreadView.tsx b/client/electron/src/components/ThreadView.tsx index a5321f8..6c7e4d8 100644 --- a/client/electron/src/components/ThreadView.tsx +++ b/client/electron/src/components/ThreadView.tsx @@ -1,299 +1,435 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ThreadNode, ThreadPage } from '../types'; import * as api from '../api/client'; import { useMailStore } from '../stores/mailStore'; -import { participantAddress } from '../lib/replyTarget'; -import type { ThreadNode } from '../types'; -import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons'; - -import BackButton from './BackButton'; -import { useIsNarrow } from '../hooks/useIsNarrow'; /** - * 对话树视图(从线索根整树展开,分块加载)。 + * 对话树 —— 画布式真树(可拖动 / 可缩放 / 点节点看正文)。 * - * 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话, - * 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。 + * ★ 2026-10-04:这就是用户要的那棵树。此前的实现是一列**缩进列表** + * (ThreadView),改了几轮排序与竖线仍是「看起来像平铺」—— + * 因为缩进列表表达不了**分支**:59 层里多个兄弟追问并排时, + * 缩进线断成一片,而画布上每条边都是独立的一条线。 * - * 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」 - * 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件 - * 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的 - * 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。 + * 用户原话:「把原本的对话树替换为新版本」。 * - * 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。 + * # 数据源:邮件线索(不是会话列表) * - * 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链 - * 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式, - * 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。 + * `/mail/{id}/thread` 返回整条线索的**全部**邮件及其 `parent_mail_id`。 + * 实测最深的线索有 **205 封、深达 59 层** + * (`redeploy-pi-bridge-授权唤醒修复`,Agent↔Agent 连续授权唤醒 59 轮)。 + * + * # 布局:分层(简化 Reingold–Tilford) + * + * 先算每棵子树的**宽度**,父节点水平居中于其子树之上。 + * ⚠️ 不用力导向:力导向每次渲染位置都变 ⇒ 点不准,也无法两次打开 + * 画成同一棵树(截图对比与用户心智都依赖稳定)。 */ -export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) { - const [nodes, setNodes] = useState([]); - const [hidden, setHidden] = useState(0); - const [hasMore, setHasMore] = useState(false); - const [nextOffset, setNextOffset] = useState(0); - const [err, setErr] = useState(''); - const [initial, setInitial] = useState(true); - const [loading, setLoading] = useState(false); - const scrollRef = useRef(null); - const bottomSentinel = useRef(null); - const anchorRef = useRef(null); - // 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索 - const gen = useRef(0); - // loading 的同步副本。setState 是异步的,哨兵连续进入视口时 - // 读 state 会看到旧的 false 而并发发两个请求。 - const busy = useRef(false); +const NODE_W = 260; +const HEADER_H = 78; +const GAP_X = 20; +const GAP_Y = 14; - const sortNodes = (list: ThreadNode[]) => - list - .slice() - .sort((a, b) => - a.depth - b.depth || a.created_at.localeCompare(b.created_at) - ); +const MIN_ZOOM = 0.04; // ★ 200 节点同层时横线可达 56000px;fit 要能缩到看全 +const MAX_ZOOM = 2.5; +const PAGE_SIZE = 200; - const merge = useCallback((incoming: ThreadNode[]) => { - setNodes(prev => { - const seen = new Set(prev.map(n => n.mail_id)); - return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]); +interface Placed extends ThreadNode { + x: number; + y: number; + children: Placed[]; +} + +export default function ThreadView({ mailID, onClose }: { + mailID: string; + onClose: () => void; +}) { + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(mailID); + const [detail, setDetail] = useState(null); + /** 已经为哪封做过自动 fit —— 防止 ResizeObserver 反复重置用户拖好的视口。 */ + const [fittedFor, setFittedFor] = useState(''); + /** 最新节点:fitTo 只读它,避免闭包拿到旧数组(见 fitTo 注释)。 */ + const nodesRef = useRef([]); + /** 用户是否拖动/缩放过 —— 之后容器尺寸变化不再自动重置视口。 */ + const touched = useRef(false); + + // 视口:平移 + 缩放 + const [pan, setPan] = useState({ x: 48, y: 40 }); + const [zoom, setZoom] = useState(1); + const drag = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null); + const wrapRef = useRef(null); + const openMailByID = useMailStore(s => s.openMailByID); + + /** + * ★ 必须读 ref 而不是接收参数(用户 2026-10-04 实测定位)。 + * 传参时 ResizeObserver / rAF 回调闭包捕获的是**上一次渲染**的 nodes + * (旧数组:bounds 11340x7193),而屏幕上已经是新数组(7840x5060)。 + * 同一份公式、同一个容器,自动那次算出 zoom=1.4、手动那次算出 0.1217 —— + * 差别只在「拿到的是哪份数据」。 + */ + const fitTo = useCallback(() => { + const flat = nodesRef.current; + if (!flat.length) return; + const el = (wrapRef.current ?? document.querySelector( + '[data-am-tree-canvas]')) as HTMLElement | null; + const rect = el?.getBoundingClientRect(); + // 量不到尺寸(容器还没布局)时直接返回,交给上层 effect 的 rAF 重试。 + if (!rect || !rect.width || !rect.height) return; + const minX = Math.min(...flat.map(n => n.x)); + const maxX = Math.max(...flat.map(n => n.x + NODE_W)); + const maxY = Math.max(...flat.map(n => n.y + HEADER_H)); + const z = Math.max(MIN_ZOOM, Math.min(1.4, + Math.min((rect.width - 48) / Math.max(maxX - minX, 1), + (rect.height - 48) / Math.max(maxY, 1)))); + setZoom(z); + setPan({ x: 24 - minX * z, y: 24 }); + }, []); + + // 拉线索。⚠️ 只拉一次:每次 nodes 变化都重拉会把用户拖好的视口反复重置。 + useEffect(() => { + let alive = true; + setLoading(true); + setError(null); + setSelected(mailID); + (async () => { + try { + const page: ThreadPage = await api.getMailThread(mailID, { + offset: 0, limit: PAGE_SIZE, + }); + if (!alive) return; + const built = layout(page.nodes || []); + setNodes(built); + // 清掉守卫,让下面的自动 fit effect 能跑(换一封邮件时也要重 fit)。 + setFittedFor(''); + } catch (e) { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + setNodes([]); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { alive = false; }; + }, [mailID, fitTo]); + + // ── 拖动平移(点在节点上时不启动)── + const onPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest('[data-node]')) return; + touched.current = true; + drag.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }, [pan]); + const onPointerMove = useCallback((e: React.PointerEvent) => { + const d = drag.current; + if (!d) return; + setPan({ x: d.panX + (e.clientX - d.x), y: d.panY + (e.clientY - d.y) }); + }, []); + const endDrag = useCallback(() => { drag.current = null; }, []); + + // ── 滚轮缩放,锚定光标 ── + const onWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + // ★ 组件可能已卸载(切走邮件 / 关闭面板),但滚轮事件仍在飞。 + // 直接解引用 e.currentTarget / wrapRef.current ⇒ + // TypeError: Cannot read properties of null + // (用户 2026-10-04:「一缩放还崩溃」,浏览器实测复现)。 + const el = e.currentTarget as HTMLElement | null; + if (!el) return; + touched.current = true; + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + const rect = el.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + setZoom(z => { + const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * factor)); + // 保持光标下的点不动:只 setZoom 会以原点缩放,内容会「跳」出去 + setPan(p => ({ + x: cx - ((cx - p.x) * next) / z, + y: cy - ((cy - p.y) * next) / z, + })); + return next; }); }, []); - // 首屏 - useEffect(() => { - const myGen = ++gen.current; - setNodes([]); - setHidden(0); - setErr(''); - setInitial(true); - busy.current = true; - api - .getMailThread(mailID, { offset: 0, limit: 60 }) - .then(p => { - if (gen.current !== myGen) return; - setNodes(sortNodes(p.nodes)); - setHidden(p.hidden); - setHasMore(p.has_more); - setNextOffset(p.next_offset); - }) - .catch(e => { - if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败'); - }) - .finally(() => { - if (gen.current === myGen) { - setInitial(false); - busy.current = false; - } +// ⚠️ 不在 load 里调 fitTo,而是在 nodes 落地、且容器已布局时调。 + // 实测两条死路(用户 2026-10-04 现场): + // · load 里直调 → 首帧 rect 未完成 ⇒ 算出 z=1.4(上限),永不纠正; + // · load 里套 rAF → 仍被初始 state 的一次重渲染盖掉。 + // 「适应」按钮手动点则是对的(实测 12%)⇒ 逻辑没问题,是**时机**。 + useEffect(() => { + if (!nodes.length || fittedFor === mailID) return; + let raf = 0; + const run = () => { fitTo(); setFittedFor(mailID); }; + raf = requestAnimationFrame(() => { raf = requestAnimationFrame(run); }); + // 容器尺寸真的变了(侧栏收放、窗口缩放)时也要重算 + let ro: ResizeObserver | undefined; + if (typeof ResizeObserver !== 'undefined' && wrapRef.current) { + ro = new ResizeObserver(() => { + if (touched.current) return; // 用户拖/缩过就别再自动重置 + cancelAnimationFrame(raf); + raf = requestAnimationFrame(run); }); - }, [mailID]); - - // 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后, - // 不滚过去的话用户点开一封邮件却停在整条线索的开头。 - useEffect(() => { - if (initial || !anchorRef.current) return; - anchorRef.current.scrollIntoView({ block: 'center' }); - }, [initial]); - - const loadMore = useCallback(async () => { - if (busy.current || !hasMore) return; - const myGen = gen.current; - busy.current = true; - setLoading(true); - try { - const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 }); - if (gen.current !== myGen) return; - merge(p.nodes); - setHidden(h => h + p.hidden); - setHasMore(p.has_more); - setNextOffset(p.next_offset); - } catch (e) { - if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败'); - } finally { - if (gen.current === myGen) setLoading(false); - busy.current = false; + ro.observe(wrapRef.current); } - }, [mailID, merge, hasMore, nextOffset]); + return () => { ro?.disconnect(); cancelAnimationFrame(raf); }; + }, [nodes, mailID, fitTo, fittedFor]); - // 底部哨兵进入视口就续取。rootMargin 提前 200px 触发, - // 让加载在用户滑到边界前完成。 - useEffect(() => { - const root = scrollRef.current; - if (!root) return; - const obs = new IntersectionObserver( - entries => { - for (const e of entries) if (e.isIntersecting) loadMore(); - }, - { root, rootMargin: '200px' } - ); - if (bottomSentinel.current) obs.observe(bottomSentinel.current); - return () => obs.disconnect(); - }, [loadMore]); + const fit = useCallback(() => { + touched.current = false; + fitTo(); + setFittedFor(mailID); + }, [fitTo, mailID]); + + const pick = useCallback((n: Placed) => { + setSelected(n.mail_id); + setDetail(n); + }, []); + + const openBody = useCallback(() => { + if (detail) openMailByID(detail.mail_id); + }, [detail, openMailByID]); + + const flat = useMemo(() => flatten(nodes), [nodes]); + // ★ ref 要存**扁平**列表:nodes 是「根节点数组」,只有 roots.length 个。 + // 曾经直接存 nodes ⇒ fit 拿到 1 个节点、span 260 ⇒ z 顶到 1.4 上限 + // ⇒ 内容 11340x7193 只露左上角(用户 2026-10-04 实测)。 + nodesRef.current = flat; + const bounds = useMemo(() => boundsOf(flat), [flat]); + const maxDepth = useMemo( + () => flat.reduce((m, n) => Math.max(m, n.depth), 0), [flat]); return ( -
-
- {/* 窄屏下对话树是盖在列表上的一层,得有返回出口。 - 它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表, - 关闭只收起树、留在这封邮件上。 */} - - 对话树 + // ★ h-full min-h-0 必须在这:父容器靠它撑满,缺了则高度链断掉。 +
+
+

对话树

- 已加载 {nodes.length} 封 - {hasMore && ',滑动加载更多'} - {hidden > 0 && `,${hidden} 封无权查看`} + {loading ? '载入中…' : `${flat.length} 封`} + {!loading && maxDepth > 0 && ` · 最深 ${maxDepth} 层`}
- {loading && } - + {Math.round(zoom * 100)}% + + + -
+
-
- {initial && ( -
- - 加载中 + {error &&

{error}

} + +
+
+ {loading && ( +

+ 载入线索… +

+ )} + {!loading && !flat.length && ( +

+ 这条线索里还没有其它邮件 +

+ )} + +
+ {/* ★ 边用绝对定位的 div 画,不用 。 + 会让浏览器按 + **内容包围盒**分配光栅化画布:205 节点 × 缩放后可达 10^5 px, + 一次滚轮就抽干纹理内存 ⇒ 渲染进程崩溃(用户 2026-10-04: + 「一缩放还崩溃」)。div 没有这个坑。*/} + {edges(flat).map(e => ( +
+
+ + + +
+
+ ))} + {flat.map(n => ( + + ))} +
+
+ + {/* 点节点后的正文预览(打开正文仍走既有的邮件视图)*/} + {detail && ( +
+
+

{detail.subject}

+

+ {detail.from_name} → {detail.to_name} · L{detail.depth} +

+
+
+
+                {detail.body_preview || detail.body || '(无正文预览)'}
+              
+
+
)} - {err &&

{err}

} - - {!initial && ( - <> -
- {nodes.map(n => ( - - ))} -
- - {hasMore && ( - - )} -
- - )}
); } -function Node({ - node, - anchorID, - anchorRef -}: { - node: ThreadNode; - anchorID: string; - anchorRef?: React.RefObject; -}) { - const openMailByID = useMailStore(s => s.openMailByID); - const narrow = useIsNarrow(); - const isPermission = node.mail_type === 'permission_request'; - const isAnchor = node.mail_id === anchorID; - const ccCount = node.cc_list?.length ?? 0; - // 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。 - // 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。 - const isForward = node.subject.startsWith('Fwd: '); - // 缩进:每级的像素数与上限都随屏宽变。 - // - // 原先固定「每级 20px、上限 8 级」= 最多 160px。在 320px 屏上容器还要去掉 - // px-4 的 32px 与连接线的 18px,卡片只剩 110px —— 发件人一行就被 truncate 吃掉。 - // 窄屏改成每级 10px、上限 5 级(最多 50px),层级仍然看得出来,卡片还有余地。 - const step = narrow ? 10 : 20; - const maxDepth = narrow ? 5 : 8; - const indent = Math.min(Math.max(node.depth, 0), maxDepth) * step; - const time = new Date(node.created_at).toLocaleString('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); +// ── 布局:按子树宽度分配列,父居中于子之上 ── - return ( -
- {indent > 0 && ( -
- )} - -
- ); +function layout(nodes: ThreadNode[]): Placed[] { + if (!nodes.length) return []; + const byId = new Map(); + for (const n of nodes) byId.set(n.mail_id, { ...n, x: 0, y: 0, children: [] }); + + const roots: Placed[] = []; + for (const n of byId.values()) { + const p = n.parent_mail_id ? byId.get(n.parent_mail_id) : undefined; + if (p && p.session_id === n.session_id && p.mail_id !== n.mail_id) p.children.push(n); + else roots.push(n); + } + + // parent_mail_id 是外部可写字段:脏数据成环时按路径打断,否则递归不死 + const onPath = new Set(); + const width = new Map(); + const calc = (n: Placed): number => { + if (width.has(n.mail_id)) return width.get(n.mail_id)!; + if (onPath.has(n.mail_id)) { n.children = []; return NODE_W; } + onPath.add(n.mail_id); + const kidsW = n.children.reduce((s, c) => s + calc(c) + GAP_X, 0); + const w = Math.max(NODE_W, kidsW - (n.children.length ? GAP_X : 0)); + onPath.delete(n.mail_id); + width.set(n.mail_id, w); + return w; + }; + for (const r of roots) calc(r); + + let cursor = 0; + const place = (n: Placed): Placed => { + const w = calc(n); + let x: number; + if (!n.children.length) { + x = cursor; + cursor += w + GAP_X; + } else { + const kids = n.children + .slice() + .sort((a, b) => a.created_at.localeCompare(b.created_at)) + .map(place); + x = (kids[0].x + kids[kids.length - 1].x) / 2; + return { ...n, x, y: n.depth * (HEADER_H + GAP_Y), children: kids }; + } + return { ...n, x, y: n.depth * (HEADER_H + GAP_Y), children: [] }; + }; + + return roots.map(r => place(r)); } + +function flatten(roots: Placed[]): Placed[] { + const out: Placed[] = []; + const walk = (n: Placed) => { out.push(n); n.children.forEach(walk); }; + roots.forEach(walk); + return out; +} + + +interface Edge { + key: string; + d: string; + /** 边自身包围盒(用于给 svg 一个真实尺寸) */ + x1: number; y1: number; dx: number; dy: number; +} + +function edges(list: Placed[]): Edge[] { + const out: Edge[] = []; + for (const n of list) { + for (const c of n.children) { + const x1 = n.x + NODE_W / 2; + const y1 = n.y + HEADER_H; + const x2 = c.x + NODE_W / 2; + const y2 = c.y; + const my = (y1 + y2) / 2; + out.push({ + key: `${n.mail_id}->${c.mail_id}`, + d: `M 0 0 C 0 ${my - y1}, ${x2 - x1} ${my - y1}, ${x2 - x1} ${y2 - y1}`, + x1: Math.min(x1, x2), + y1, + dx: Math.max(Math.abs(x2 - x1), 1), + dy: Math.max(Math.abs(y2 - y1), 1), + }); + } + } + return out; +} + +/** 内容包围盒:给绝对定位的画布一个真实尺寸(否则子元素定位无参照)。 */ +function boundsOf(list: Placed[]): { w: number; h: number } { + if (!list.length) return { w: 0, h: 0 }; + return { + w: Math.max(...list.map(n => n.x + NODE_W)), + h: Math.max(...list.map(n => n.y + HEADER_H)), + }; +} \ No newline at end of file diff --git a/client/electron/test/calendar-slide.test.mjs b/client/electron/test/calendar-slide.test.mjs new file mode 100644 index 0000000..da91893 --- /dev/null +++ b/client/electron/test/calendar-slide.test.mjs @@ -0,0 +1,62 @@ +/* +Electron 日历页的判据(2026-10-04)。 + +★ 起因:用户实测报告「点击切换月份的按钮,直接从10月跳到八月卡死」。 + 我用 CDP 真机复现时看到的是: + animation-play-state: running,opacity: 0, + transform: translateX(-94.8px),元素 x = -15(跑出视口) + ⇒ 日历整块滑出屏幕且**永不回来**。 +*/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { code } from './lib/read.mjs'; + +const src = p => code(`src/${p}`); + +test('★ 翻页动画状态必须是 state,不能是 ref', () => { + const cal = src('components/CalendarView.tsx'); + // ★ 核心:注释写着「用**声明式** key + 类」,实现却是命令式 ref。 + // ref 改动不触发重渲染 ⇒ slideClass 只能用上一次的方向, + // 且 class 名永不变 ⇒ 动画只播一次就停在起始帧 ⇒ 观感「卡死」。 + assert.match(cal, /const \[slide, setSlide\] = useState/, + '★ 翻页动画状态还在用 ref —— ref 改动不触发重渲染,动画会卡在起始帧'); + assert.doesNotMatch(cal, /navigated\.current|slideDir\.current/, + '★ 还在用 navigated/slideDir ref(命令式)—— 与注释宣称的「声明式」矛盾'); +}); + +test('★ 每次翻页都要重放一次动画(key 含 seq)', () => { + const cal = src('components/CalendarView.tsx'); + assert.match(cal, /seq: \(s\?\.seq \?\? 0\) \+ 1/, 'seq 没有递增'); + assert.match(cal, /key=\{`\$\{anchor\.getTime\(\)\}-\$\{scale\}-\$\{slide\?\.seq \?\? 0\}`\}/, + '★ key 不含 seq —— 同方向连续翻页时动画可能不重放'); +}); + +test('★ 动画结束必须摘掉 class(fill-mode:both 的兜底)', () => { + const cal = src('components/CalendarView.tsx'); + // ★ animation-fill-mode: both 会把元素留在帧之间。动画一旦没跑到终点 + // (连点 / 切后台 / reduced-motion 切换),元素就永久停在 + // translateX(-12%) + opacity:0 —— 实测元素 x=-15,整块日历跑出视口。 + assert.match(cal, /setTimeout\(\(\) => setSlide\(null\)/, + '★ 动画结束后没有清掉 class —— 元素会永久停在动画帧上(用户实测「卡死」)'); +}); + +test('翻页方向只有一个来源(按钮与手势共用 shift)', () => { + const cal = src('components/CalendarView.tsx'); + assert.match(cal, /function shift\(dir: 1 \| -1\)/, '没有统一的 shift()'); + const n = (cal.match(/shift\(/g) || []).length; + assert.ok(n >= 3, `shift 只有 ${n} 处引用,按钮/手势可能各写一套`); +}); + +test('翻页用函数式更新(连点不丢更新)', () => { + const cal = src('components/CalendarView.tsx'); + assert.match(cal, /setAnchor\(a => addMonths\(a, dir\)\)/, + '★ setAnchor 没用函数式更新 —— 连点时会基于同一个旧 anchor 算,跳月会丢步'); + assert.match(cal, /addDays\(a, dir \* 7\)/, '周视图没有按 7 天翻页'); +}); + +test('addMonths 先归到 1 号(setMonth 会溢出)', () => { + const lib = src('lib/calendar.ts'); + assert.match(lib, /new Date\(d\.getFullYear\(\), d\.getMonth\(\) \+ n, 1\)/, + '★ addMonths 没有先归到 1 号 —— 1月31日加一月会溢出到 3 月'); +}); \ No newline at end of file diff --git a/client/electron/test/thread-view-tree.test.mjs b/client/electron/test/thread-view-tree.test.mjs new file mode 100644 index 0000000..f46a48e --- /dev/null +++ b/client/electron/test/thread-view-tree.test.mjs @@ -0,0 +1,201 @@ +/* +对话树 = 画布式真树(可拖动 / 可缩放 / 点节点看正文)的判据(2026-10-04)。 + +★ 演进(用户连续几轮反馈「还是老版」): + ① 原实现是缩进列表 → 我改排序/竖线/缩进上限,仍像平铺(缩进列表 + **表达不了分支**:59 层里多个兄弟并排时线断成一片) + ② 整份重写为缩进列表的改良版 → 仍不是用户要的东西 + ③ **本轮**:用户明确「直接用你之前写的那个可拖动缩放的对话树界面替换 + 现在这个对话树界面」⇒ ThreadView 改成画布树。 + +★ 本文件盯画布树的三个能力 + 一条禁止退回的守卫。 +*/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { code, prose } from './lib/read.mjs'; + +const src = p => code(`src/${p}`); + +test('会话视图保持平铺(范围纪律,用户 2026-10-04)', () => { + const view = src('components/MailView.tsx'); + assert.match(view, /currentSessionMails\.map\(/, '会话视图不该变成树'); +}); + +test('★ 可拖动:指针拖拽改平移,且绑在容器上', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /const onPointerDown = useCallback/, '没有 onPointerDown 实现'); + // ★ 必须查**绑定**而不只是字面量:变异把 JSX 上的 onPointerDown 换成注释时, + // 早期版本判据全绿(函数还在,只是没接上)。 + assert.match(tv, /onPointerDown=\{onPointerDown\}/, 'onPointerDown 没绑到容器 ⇒ 拖不动'); + assert.match(tv, /onPointerMove=\{onPointerMove\}/, 'onPointerMove 没绑 ⇒ 拖不动'); + assert.match(tv, /setPointerCapture/, '未捕获指针 —— 拖出元素会中断'); + assert.match(tv, /cursor-grab/, '缺拖动光标提示'); + // 点在节点上时不启动平移(否则点一下会把整棵树拖走) + assert.match(tv, /closest\('\[data-node\]'\)/, '点节点也会启动平移'); +}); + +test('★ 可缩放:滚轮缩放且锚定光标', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /const onWheel = useCallback/, '没有 onWheel 实现'); + assert.match(tv, /onWheel=\{onWheel\}/, '★ onWheel 没绑 ⇒ 滚轮不缩放'); + // ★ 必须断言**结果里真的用上了光标坐标**,而不只是「代码里出现过它」。 + // 变异「把 setPan 改成原样返回(坐标照算但不用)」时, + // `clientX - rect.left` 仍在源码里 ⇒ 只查字面量会假绿。 + assert.match(tv, /setPan\(p =>\s*\(\{\s*\n?\s*x: cx -/, + '★ 缩放结果没有按光标锚定 —— 缩放时内容会从光标下「跳」走'); + assert.match(tv, /MIN_ZOOM/, '没有缩放下限'); + assert.match(tv, /适应/, '没有「适应」按钮(60% 缩放下 205 封的树没法看)'); +}); + +test('★ 可点击看正文', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /onClick=\{\(\) => pick\(n\)\}/, '节点不可点击'); + assert.match(tv, /setDetail\(n\)/, '点了没有正文'); + assert.match(tv, /body_preview \|\| detail\.body/, '正文没有渲染'); + assert.match(tv, /openMailByID\(detail\.mail_id\)/, '没有「打开完整正文」的出口'); +}); + +test('★ 布局是分层画布,不是缩进列表', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /function layout\(nodes: ThreadNode\[\]\): Placed\[\]/, '没有画布布局'); + assert.match(tv, /x: number/, '节点没有坐标 ⇒ 不是画布'); + assert.match(tv, /nodes - Math\.max|calc\(c\)/, '没有按子树宽度分配列'); + // ⚠️ 力导向:位置每次变 ⇒ 点不准、两次打开画成不同样 + assert.doesNotMatch(tv, /simulation|velocity|forceLayout/, + '用了力导向 —— 节点位置不稳定,点不准'); + // ★ 禁止退回缩进列表 + assert.doesNotMatch(tv, /paddingLeft: indent|Math\.min\(Math\.max\(node\.depth/, + '★ 又退回缩进列表了 —— 那表达不了分支,正是「看起来还是平铺」的原因'); +}); + +test('数据源是邮件线索(不是会话列表)', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /getMailThread/, '没有读邮件线索'); + assert.doesNotMatch(tv, /listSessionTree|listContacts/, + '用了会话级数据 —— 那是 198 个会话,不是这条线索的 205 封邮件'); + const m = prose('src/components/ThreadView.tsx').match(/PAGE_SIZE = (\d+)/); + assert.ok(Number(m[1]) >= 200, + `首屏 ${m[1]} 封 —— 实测最深的线索有 205 封,60 会把后半截藏起来`); +}); + +test('parent_mail_id 外部可写 ⇒ 防环 + 不丢信', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /onPath\.has\(n\.mail_id\)/, + '无环保护 —— 成环会递归不死(205 封的面板直接白屏)'); + assert.match(tv, /p\.mail_id !== n\.mail_id/, '没排除自引用'); +}); + +test('★ 打开就 fit(否则 200 节点只露左上角 = 没撑满)', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /const run = \(\) => \{ fitTo\(\); setFittedFor\(mailID\); \};/, + '没有自动 fit 的入口'); + // ★ 实测两条死路:load 里直调 → 首帧 rect 未完成 ⇒ 算出 1.4 上限值; + // load 里套 rAF → 仍被初始 state 的重渲染盖掉。 + // 正确位置是「nodes 落地后、且只做一次」的 effect。 + assert.match(tv, /if \(!nodes\.length \|\| fittedFor === mailID\) return;/, + '★ 自动 fit 没有只做一次的守卫 —— ResizeObserver 会反复重置用户拖好的视口'); + assert.match(tv, /raf = requestAnimationFrame\(\(\) => \{ raf = requestAnimationFrame\(run\); \}\);/, + '★ 没有等布局完成再 fit(双 rAF)—— 首帧量到的尺寸是错的'); + // ★ 关键回归:首帧 wrapRef 还是 null,量不到尺寸就 return ⇒ fit 永远不跑 + assert.match(tv, /if \(!rect \|\| !rect\.width \|\| !rect\.height\) return;/, + '量不到尺寸时没有安全返回'); + assert.match(tv, /new ResizeObserver/, '没有容器尺寸变化时的重算'); + assert.match(tv, /new ResizeObserver/, '没有监听容器尺寸变化'); + assert.match(tv, /ro\.observe\(wrapRef\.current\)/, 'ResizeObserver 没有观察画布容器'); + assert.match(tv, /raf = requestAnimationFrame\(\(\) => \{ raf = requestAnimationFrame\(run\); \}\);/, + '没有等布局完成再 fit(双 rAF)—— 首帧量到的尺寸是错的'); + // fit 要同时按宽高算,只看宽会被 59 层(高 7000+)拖成极小 + const m = tv.match(/rect\.height - 48\) \/ Math\.max\(maxY, 1\)/); + assert.ok(m, 'fit 只按宽度算 ⇒ 59 层的树被压成一条'); +}); + +test('★ 滚轮不崩:不能解引用可能为 null 的元素', () => { + const tv = src('components/ThreadView.tsx'); + // 用户实测:放大后 TypeError: Cannot read properties of null + assert.match(tv, /const el = e\.currentTarget as HTMLElement \| null;/, + '没有先取一次可空引用'); + assert.match(tv, /if \(!el\) return;/, '★ 没做空值守卫 —— 卸载后滚轮会抛异常'); + assert.doesNotMatch(tv, /\(e\.currentTarget as HTMLElement\)\.getBoundingClientRect\(\)/, + '★ 直接解引用 currentTarget —— 卸载后即为 null'); +}); + +/* +★ 两个线上症状的判据(用户 2026-10-04 实测报告,我第一轮没看出来)。 + 两轮都只跑了 tsc + 判据就宣布完成 —— 「类型没错」不等于「页面能用」。 +*/ + +test('★ 撑满:ThreadView 必须被 flex 容器包住', () => { + const view = src('components/MailView.tsx'); + // 症状:「页面没有撑满」。threadOf 分支是**裸返回**, + // 不像正常分支那样带 flex-1 min-w-0 min-h-0 ⇒ 高度链断、内容塌陷。 + const m = view.match(/if \(threadOf\)\s*\{[\s\S]{0,400}?return \(([\s\S]{0,400}?)\);/); + assert.ok(m, '找不到 threadOf 分支'); + assert.match(m[1], /flex-1/, + '★ threadOf 分支裸返回 ThreadView,没有 flex-1 ⇒ 面板不撑满(用户实测「页面没有撑满」)'); + assert.match(m[1], /min-h-0/, '没有 min-h-0 ⇒ 高度链断'); + assert.match(m[1], /min-w-0/, '没有 min-w-0 ⇒ 宽屏下被内容撑出横向滚动'); +}); + +test('★ 缩放不崩:边不能画在 width={1} 的 overflow-visible svg 里', () => { + const tv = src('components/ThreadView.tsx'); + // 症状:「一缩放还崩溃」。 会让浏览器 + // 按**内容包围盒**光栅化:205 节点缩放后包围盒可达 10^5 px,一次滚轮就抽干纹理内存。 + assert.doesNotMatch(tv, /]*overflow-visible/, + '★ 用了 overflow-visible 的溢出 svg —— 缩放时渲染进程会崩(用户实测)'); + assert.doesNotMatch(tv, /className="absolute overflow-visible[^"]*"\s*width=\{1\}/, + '★ 1×1 的溢出 svg(同上)'); + // 每条边要有自己的包围盒,svg 才能拿到真实尺寸 + assert.match(tv, /viewBox=\{`0 0 /, '边没有独立包围盒 ⇒ svg 尺寸仍是 1×1'); + assert.match(tv, /dx: number; dy: number;/, 'Edge 没有包围盒字段'); +}); + +test('画布有真实尺寸(绝对定位的子元素需要参照)', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /const bounds = useMemo\(\(\) => boundsOf\(flat\)/, + '没有算内容包围盒'); + assert.match(tv, /position: 'absolute',[\s\S]{0,200}?width: bounds\.w/, + '画布没设尺寸 —— 绝对定位的节点没有参照物'); +}); + +/* +★ 这一格盯的是本轮最贵的两个坑(用户 2026-10-04 实测发现「页面没撑满」)。 + +实测现象:200 节点 / 55 层的树,打开后 zoom=140%、pan.x=-9393, +内容包围盒 11340x7193 —— 屏幕上只看得到左上角一小块。 +而**手动点「适应」按钮算出来的却是正确的 0.1217**。 +同一份公式、同一个容器,一个对一个错 ⇒ 差别只在**拿到的是哪份数据**。 +*/ + +test('★ fit 必须读**扁平**节点,不是根节点数组', () => { + const tv = src('components/ThreadView.tsx'); + // ★ nodes 是「根节点数组」(这棵树上只有 1 个根), + // 直接喂给 fit ⇒ span 260 ⇒ z 顶到上限 1.4 ⇒ 只露左上角。 + assert.match(tv, /nodesRef\.current = flat;/, + '★ ref 存的是 nodes(根数组)而不是 flat —— fit 只看得到根,缩放算错'); + const m = tv.match(/const flat = useMemo\(\(\) => flatten\(nodes\), \[nodes\]\);[\s\S]{0,200}?nodesRef\.current = flat;/); + assert.ok(m, 'nodesRef.current = flat 不在 flat 定义之后 —— 顺序反了会拿到上一轮的值'); + // fitTo 不接受参数,只读 ref ⇒ 闭包不会捕获旧数组 + assert.match(tv, /const fitTo = useCallback\(\(\) => \{/, 'fitTo 又开始接收参数了'); + assert.match(tv, /const flat = nodesRef\.current;/, 'fitTo 没有读 ref'); +}); + +test('★ 自动 fit 的 effect 必须依赖 nodes(数据到了要重跑)', () => { + const tv = src('components/ThreadView.tsx'); + // 实测:effect 只在 nodes=0 时跑了一次就 return,之后数据到了不再触发 + // ⇒ 永远停在初始 zoom。setFittedFor('') 与 setNodes 同批次会被 React bail out, + // 所以依赖里必须有 nodes。 + const m = tv.match(/useEffect\(\(\) => \{\s*\n?\s*if \(!nodes\.length \|\| fittedFor === mailID\) return;[\s\S]*?\}, \[([^\]]*)\]\);/); + assert.ok(m, '找不到自动 fit 的 effect'); + assert.match(m[1], /nodes/, '★ effect 依赖里没有 nodes —— 数据到达后不会重跑(实测卡在初始 zoom)'); +}); + +test('用户拖/缩过之后不再自动重置视口', () => { + const tv = src('components/ThreadView.tsx'); + assert.match(tv, /const touched = useRef\(false\)/, '没有「用户动过」标记'); + assert.match(tv, /if \(touched\.current\) return;/, + 'ResizeObserver 会把用户拖好的视口反复重置'); + // 手动「适应」要清标记 + assert.match(tv, /touched\.current = false;\s*\n\s*fitTo\(\);/, + '手动「适应」后没有清标记 ⇒ 之后再也不能自动重算'); +});