fix(webui)★★: 对话树改成画布式真树 + 修两个线上 bug;日历翻月卡死

移植自本地 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` 分支裸返回 `<ThreadView/>`,缺
   `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 才撤回)。
**先问清楚,比继续猜便宜得多。**
This commit is contained in:
2026-10-05 09:09:36 +08:00
parent 6e5ea42138
commit 1f517eca4b
5 changed files with 710 additions and 277 deletions

View File

@ -135,22 +135,47 @@ export default function CalendarView() {
* 首次进入不动画(`navigated` 为 false),否则一打开日历就滑一下很怪。
*/
const bodyRef = useRef<HTMLDivElement | null>(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:一变就是新元素 ⇒ 动画必然重放(见上面的说明)。
<div
ref={bodyRef}
key={`${anchor.getTime()}-${scale}`}
// ★ seq 也要进 key:同一方向连续翻页时 anchor 每<><E6AF8F>都变,但若 key 不含 seq
// 而 slideClass 又只由 dir 决定,浏览器可能复用旧元素/不重放动画。
key={`${anchor.getTime()}-${scale}-${slide?.seq ?? 0}`}
className={`flex-1 min-h-0 flex ${slideClass}`}
data-slide={slideClass || 'none'}
>

View File

@ -129,9 +129,16 @@ export default function MailView() {
);
}
if (threadOf) {
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
}
if (threadOf) {
// ★ 必须带 flex-1 min-w-0 min-h-0:ThreadView 是裸返回,
// 不像下面的分支那样有这组类。没有它 ⇒ 面板不撑满、内容区高度塌陷
// (用户 2026-10-04:「页面没有撑满」)。
return (
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col bg-white">
<ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />
</div>
);
}
return (
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col bg-white">

View File

@ -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<ThreadNode[]>([]);
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<HTMLDivElement>(null);
const bottomSentinel = useRef<HTMLDivElement>(null);
const anchorRef = useRef<HTMLDivElement>(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<Placed[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string>(mailID);
const [detail, setDetail] = useState<ThreadNode | null>(null);
/** 已经为哪封做过自动 fit —— 防止 ResizeObserver 反复重置用户拖好的视口。 */
const [fittedFor, setFittedFor] = useState('');
/** 最新节点:fitTo 只读它,避免闭包拿到旧数组(见 fitTo 注释)。 */
const nodesRef = useRef<Placed[]>([]);
/** 用户是否拖动/缩放过 —— 之后容器尺寸变化不再自动重置视口。 */
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<HTMLDivElement>(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 (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
{/* 窄屏下对话树是盖在列表上的一层,得有返回出口。
它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表,
关闭只收起树、留在这封邮件上。 */}
<BackButton />
<span className="text-sm font-semibold text-gray-900">对话树</span>
// ★ h-full min-h-0 必须在这:父容器靠它撑满,缺了则高度链断掉。
<div className="flex h-full min-h-0 flex-1 flex-col bg-white">
<header className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 shrink-0">
<h2 className="text-sm font-semibold text-gray-900">对话树</h2>
<span className="text-xs text-gray-400">
已加载 {nodes.length} 封
{hasMore && ',滑动加载更多'}
{hidden > 0 && `,${hidden} 封无权查看`}
{loading ? '载入中…' : `${flat.length} 封`}
{!loading && maxDepth > 0 && ` · 最深 ${maxDepth} 层`}
</span>
<div className="flex-1" />
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
<button
onClick={onClose}
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<CloseIcon className="w-3.5 h-3.5" />
<button onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - 0.2))}
className="tap px-2 py-0.5 rounded text-2xs border border-gray-300 text-gray-600">−</button>
<span className="text-2xs text-gray-500 w-10 text-center">{Math.round(zoom * 100)}%</span>
<button onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + 0.2))}
className="tap px-2 py-0.5 rounded text-2xs border border-gray-300 text-gray-600">+</button>
<button onClick={fit}
className="tap px-2 py-0.5 rounded text-2xs border border-gray-300 text-gray-600">适应</button>
<button onClick={onClose}
className="tap text-xs text-gray-500 hover:text-gray-800 px-1.5 py-0.5 rounded hover:bg-gray-100">
关闭
</button>
</div>
</header>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
{initial && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
加载中
{error && <p className="px-4 py-2 text-xs text-red-600">{error}</p>}
<div className="flex-1 min-h-0 flex">
<div
ref={wrapRef}
data-am-tree-canvas
className="relative flex-1 min-w-0 overflow-hidden bg-gray-50 cursor-grab active:cursor-grabbing"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onWheel={onWheel}
>
{loading && (
<p className="absolute inset-0 flex items-center justify-center text-xs text-gray-400">
载入线索…
</p>
)}
{!loading && !flat.length && (
<p className="absolute inset-0 flex items-center justify-center text-xs text-gray-400">
这条线索里还没有其它邮件
</p>
)}
<div style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: '0 0',
position: 'absolute',
left: 0,
top: 0,
width: bounds.w,
height: bounds.h,
}}>
{/* ★ 边用绝对定位的 div 画,不用 <svg>。
<svg width={1} height={1} overflow-visible> 会让浏览器按
**内容包围盒**分配光栅化画布:205 节点 × 缩放后可达 10^5 px,
一次滚轮就抽干纹理内存 ⇒ 渲染进程崩溃(用户 2026-10-04:
「一缩放还崩溃」)。div 没有这个坑。*/}
{edges(flat).map(e => (
<div key={e.key} className="absolute pointer-events-none"
style={{ left: e.x1, top: e.y1, width: e.dx, height: e.dy }}>
<div className="absolute overflow-hidden"
style={{ width: Math.max(Math.abs(e.dx), 1), height: Math.max(Math.abs(e.dy), 1) }}>
<svg className="absolute" width={Math.max(Math.abs(e.dx), 1)}
height={Math.max(Math.abs(e.dy), 1)}
viewBox={`0 0 ${Math.max(Math.abs(e.dx), 1)} ${Math.max(Math.abs(e.dy), 1)}`}
preserveAspectRatio="none">
<path d={e.d} fill="none" stroke="#cbd5e1" strokeWidth={1.5} />
</svg>
</div>
</div>
))}
{flat.map(n => (
<button
key={n.mail_id}
data-node
onClick={() => pick(n)}
className={`absolute text-left rounded-lg border px-2.5 py-1.5 shadow-sm transition-colors cursor-pointer ${
selected === n.mail_id
? 'border-blue-400 ring-1 ring-blue-100'
: 'border-gray-200 bg-white hover:border-blue-300'
} ${n.mail_id === mailID ? 'border-blue-500' : ''}`}
style={{ left: n.x, top: n.y, width: NODE_W, height: HEADER_H }}
>
<div className="flex items-center gap-1">
<span className="text-2xs font-semibold text-gray-900 truncate">
{n.from_name} → {n.to_name}
</span>
{n.depth > 0 && (
<span className="ml-auto text-3xs text-gray-400 border border-gray-200 rounded px-1 shrink-0">
L{n.depth}
</span>
)}
</div>
<p className="text-3xs text-gray-600 mt-0.5 line-clamp-2">{n.subject}</p>
<p className="text-3xs text-gray-400 mt-0.5 truncate">
{new Date(n.created_at).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})}
{n.attachment_count > 0 ? ` · 附件 ${n.attachment_count}` : ''}
{n.cc_list?.length ? ` · 抄送 ${n.cc_list.length}` : ''}
</p>
</button>
))}
</div>
</div>
{/* 点节点后的正文预览(打开正文仍走既有的邮件视图)*/}
{detail && (
<div className="w-[320px] shrink-0 border-l border-gray-200 flex flex-col">
<div className="px-3 py-2 border-b border-gray-200">
<p className="text-xs font-semibold">{detail.subject}</p>
<p className="text-3xs text-gray-500 mt-0.5">
{detail.from_name} → {detail.to_name} · L{detail.depth}
</p>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
<pre className="text-3xs text-gray-700 whitespace-pre-wrap break-words">
{detail.body_preview || detail.body || '(无正文预览)'}
</pre>
</div>
<button onClick={openBody}
className="m-2 px-2 py-1.5 rounded text-2xs border border-gray-300 text-gray-600 hover:bg-gray-50">
打开完整正文
</button>
</div>
)}
{err && <p className="text-xs text-red-600">{err}</p>}
{!initial && (
<>
<div className="space-y-1.5">
{nodes.map(n => (
<Node
key={n.mail_id}
node={n}
anchorID={mailID}
anchorRef={n.mail_id === mailID ? anchorRef : undefined}
/>
))}
</div>
{hasMore && (
<button
onClick={loadMore}
className="tap w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
>
加载后续往来
</button>
)}
<div ref={bottomSentinel} className="h-px" />
</>
)}
</div>
</div>
);
}
function Node({
node,
anchorID,
anchorRef
}: {
node: ThreadNode;
anchorID: string;
anchorRef?: React.RefObject<HTMLDivElement>;
}) {
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 (
<div ref={anchorRef} className="flex items-stretch" style={{ paddingLeft: indent }}>
{indent > 0 && (
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
)}
<button
onClick={() => openMailByID(node.mail_id)}
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
}`}
>
<div className="flex items-center gap-1.5">
{node.from_workspace ? (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
) : (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
)}
{/* 树节点一行里塞了 from → to、转发标记与时间,不带会话位:
整棵树本来就在同一条线索上,每个节点重复一遍别名毫无信息量。
人还是 Agent 走显式布尔;workspace 从会话取(from_workspace
对 Agent 存的是 Agent 名)。 */}
<span className="text-xs font-mono text-gray-700 truncate">
{participantAddress(
node.from_name,
node.from_human,
node.from_human ? '' : node.session_workspace || ''
)}
</span>
<span className="text-3xs text-gray-500">→</span>
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" />
{isForward && (
<span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs">
转发
</span>
)}
{node.parent_hidden && (
<span
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-3xs"
title="上一封不在你的可见范围内"
>
上游不可见
</span>
)}
{isPermission && (
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-3xs">
<ShieldIcon className="w-2.5 h-2.5" />
权限
</span>
)}
{node.attachment_count > 0 && (
<span className="inline-flex items-center gap-0.5 text-3xs text-gray-500">
<PaperclipIcon className="w-2.5 h-2.5" />
{node.attachment_count}
</span>
)}
<span className="text-3xs text-gray-500 shrink-0">{time}</span>
</div>
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
{ccCount > 0 && (
<p className="text-3xs text-gray-500 mt-0.5 truncate">
抄送 {node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')}
</p>
)}
{node.body_preview && (
<p className="text-2xs text-gray-500 mt-0.5 line-clamp-2">{node.body_preview}</p>
)}
{node.session_alias && (
<span className="text-3xs text-blue-600 font-mono">.{node.session_alias}</span>
)}
</button>
</div>
);
function layout(nodes: ThreadNode[]): Placed[] {
if (!nodes.length) return [];
const byId = new Map<string, Placed>();
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<string>();
const width = new Map<string, number>();
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)),
};
}

View File

@ -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 月');
});

View File

@ -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');
// 症状:「一缩放还崩溃」。<svg width={1} height={1} overflow-visible> 会让浏览器
// 按**内容包围盒**光栅化:205 节点缩放后包围盒可达 10^5 px,一次滚轮就抽干纹理内存。
assert.doesNotMatch(tv, /<svg[^>]*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\(\);/,
'手动「适应」后没有清标记 ⇒ 之后再也不能自动重算');
});