@aiti-ai/aiti-msg-nav 0.0.0-stage → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,871 @@
1
+ /**
2
+ * msg-nav-toolbar —— 消息滚动导航条(JSX 版)。
3
+ *
4
+ * 端口自 pi-web 原内置 components/ScrollToolbar.tsx(到最早 / 到最新 / 上一个用户
5
+ * 输入 / 下一个用户输入 + 长按保持屏幕常亮 + 拖拽换位)。核心改动:不再从 ChatWindow
6
+ * props 拿 messageRefs/visibleMessages/setVisibleCount,改由 DOM 契约发现:
7
+ * - [data-chat-scroll] 消息滚动容器(到最早/到最新的目标)
8
+ * - [data-chat-msg-role="user"] 用户消息(上一/下一个用户输入的导航目标)
9
+ * 懒分页由 core 的 sentinel IntersectionObserver 驱动:滚到容器顶端即自动加载更早
10
+ * 消息并保持视口,扩展延时重试即可,无需 setVisibleCount。
11
+ */
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+ import { getFeatures, onFeatureChange, t } from "./host-shim";
14
+
15
+ const SCROLL_ANCHOR_THRESHOLD = 80;
16
+ const CHAT_MINIMAP_WIDTH = 4;
17
+ const TOOLBAR_W = 44;
18
+ const TOOLBAR_MIN_H = 206;
19
+
20
+ /** 容器内消息包装节点(data-chat-msg-role 由 core 标记在每条可见消息外层)。 */
21
+ function getMsgEls(container: Element): HTMLElement[] {
22
+ return Array.from(container.querySelectorAll<HTMLElement>("[data-chat-msg-role]"));
23
+ }
24
+ function getUserEls(container: Element): HTMLElement[] {
25
+ return getMsgEls(container).filter((el) => el.getAttribute("data-chat-msg-role") === "user");
26
+ }
27
+ /** 元素在容器坐标系里的 top(滚动后仍正确)。 */
28
+ function elTopInContainer(el: HTMLElement | null, c: Element): number {
29
+ if (!el || !el.isConnected) return -1;
30
+ return el.getBoundingClientRect().top - c.getBoundingClientRect().top + c.scrollTop;
31
+ }
32
+
33
+ /**
34
+ * Screen Wake Lock(端口自 core hooks/useWakeLock.ts)。@returns true=API 可用。
35
+ */
36
+ function useWakeLock(active: boolean): boolean {
37
+ const [supported, setSupported] = useState(false);
38
+ useEffect(() => {
39
+ if (typeof navigator !== "undefined" && "wakeLock" in navigator) setSupported(true);
40
+ }, []);
41
+ const activeRef = useRef(active);
42
+ activeRef.current = active;
43
+ const sentinelRef = useRef<WakeLockSentinel | null>(null);
44
+ useEffect(() => {
45
+ if (!active) return;
46
+ if (typeof navigator === "undefined" || !("wakeLock" in navigator)) return;
47
+ let disposed = false;
48
+ const acquire = async () => {
49
+ if (disposed || !activeRef.current || sentinelRef.current) return;
50
+ try {
51
+ const sentinel = await navigator.wakeLock.request("screen");
52
+ if (disposed || !activeRef.current) {
53
+ sentinel.release().catch(() => {});
54
+ return;
55
+ }
56
+ sentinelRef.current = sentinel;
57
+ sentinel.addEventListener("release", () => {
58
+ if (sentinelRef.current === sentinel) sentinelRef.current = null;
59
+ if (activeRef.current && !disposed && document.visibilityState === "visible") acquire();
60
+ });
61
+ } catch {
62
+ /* NotAllowedError —— visibilitychange 时重试 */
63
+ }
64
+ };
65
+ acquire();
66
+ const onVisibility = () => { if (document.visibilityState === "visible") acquire(); };
67
+ document.addEventListener("visibilitychange", onVisibility);
68
+ return () => {
69
+ disposed = true;
70
+ document.removeEventListener("visibilitychange", onVisibility);
71
+ const s = sentinelRef.current;
72
+ sentinelRef.current = null;
73
+ if (s) s.release().catch(() => {});
74
+ };
75
+ }, [active]);
76
+ return supported;
77
+ }
78
+
79
+ interface Pos { align: "left" | "right"; y: number }
80
+ const DEFAULT_POS: Pos = { align: "right", y: 12 };
81
+
82
+ function ArrowUp() {
83
+ return (
84
+ <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
85
+ <polyline points="18 15 12 9 6 15" />
86
+ </svg>
87
+ );
88
+ }
89
+ function ArrowDown() {
90
+ return (
91
+ <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
92
+ <polyline points="6 9 12 15 18 9" />
93
+ </svg>
94
+ );
95
+ }
96
+ function PrevUserIcon() {
97
+ return (
98
+ <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
99
+ <circle cx={12} cy={5} r={1.6} />
100
+ <path d="M12 8v6" />
101
+ <path d="M8 13h8" />
102
+ </svg>
103
+ );
104
+ }
105
+ function NextUserIcon() {
106
+ return (
107
+ <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
108
+ <circle cx={12} cy={19} r={1.6} />
109
+ <path d="M12 16v-6" />
110
+ <path d="M8 11h8" />
111
+ </svg>
112
+ );
113
+ }
114
+
115
+ const btnBase: React.CSSProperties = {
116
+ pointerEvents: "auto",
117
+ display: "flex",
118
+ alignItems: "center",
119
+ justifyContent: "center",
120
+ width: TOOLBAR_W,
121
+ height: TOOLBAR_W,
122
+ borderRadius: "50%",
123
+ border: "1px solid var(--border)",
124
+ background: "color-mix(in srgb, var(--bg-panel) 92%, transparent)",
125
+ color: "var(--text-muted)",
126
+ cursor: "pointer",
127
+ boxShadow: "0 2px 8px rgba(15,23,42,0.18)",
128
+ transition: "color 0.12s, background 0.12s",
129
+ };
130
+
131
+ type TooltipKind = "earliest" | "prevUser" | "nextUser" | "latest";
132
+
133
+ const tooltipTop: Record<TooltipKind, number> = {
134
+ earliest: 17,
135
+ prevUser: 71,
136
+ nextUser: 125,
137
+ latest: 179,
138
+ };
139
+
140
+ export function ScrollNavToolbar(props: {
141
+ agentRunning?: boolean;
142
+ isMobile?: boolean;
143
+ /** core 全量展开历史(ChatMinimap onRevealHistory 同源:一次性 setVisibleCount(messages.length*2))。
144
+ * 「到最早/上一条」跳转目标在渲染窗口外时用它,不再依赖 sentinel 逐页。 */
145
+ revealHistory?: () => void;
146
+ /** 活跃会话 id:会话切换时清两个跟随窗口(容器 div 常被 React 复用,身份不变,
147
+ * RO/MO 通道不会触发,必须靠 prop 变化兜底)。 */
148
+ sessionId?: string;
149
+ }) {
150
+ const agentRunning = props.agentRunning === true;
151
+ const isMobile = typeof props.isMobile === "boolean" ? props.isMobile : (typeof window !== "undefined" && window.innerWidth < 768);
152
+ // 存 ref:跳转路径都在事件回调里,读最新闭包即可
153
+ const revealHistoryRef = useRef<(() => void) | null>(null);
154
+ revealHistoryRef.current = props.revealHistory ?? null;
155
+
156
+ // feature 变更(extman 启停)→ 强制重渲染刷新开关状态
157
+ const [, force] = useState(0);
158
+ useEffect(() => onFeatureChange(() => force((v) => v + 1)), []);
159
+ const features = getFeatures();
160
+ const showToolbar = features["show.toolbar"] !== false;
161
+ const keepAwakeFeature = features["behavior.keepAwake"] !== false;
162
+
163
+ // —— 拖拽位置(viewport 相对;localStorage 持久化,与旧内置版同 key 保留用户习惯)——
164
+ const [pos, setPos] = useState<Pos>(() => {
165
+ try {
166
+ const raw = localStorage.getItem("pi-scroll-toolbar-pos");
167
+ if (raw) {
168
+ const parsed = JSON.parse(raw) as { align?: "left" | "right"; y?: number };
169
+ if (typeof parsed.y === "number" && (parsed.align === "left" || parsed.align === "right")) {
170
+ return { align: parsed.align, y: parsed.y };
171
+ }
172
+ }
173
+ } catch { /* ignore */ }
174
+ return DEFAULT_POS;
175
+ });
176
+ const posRef = useRef(pos);
177
+ const setPosAndRef = useCallback((p: Pos) => { posRef.current = p; setPos(p); }, []);
178
+ const dragRef = useRef<{ startX: number; startY: number; startY0: number; moved: boolean; pointerId: number } | null>(null);
179
+ const toolbarRef = useRef<HTMLDivElement | null>(null);
180
+ const dragModeRef = useRef(false);
181
+ const tapNavigatedRef = useRef(false);
182
+ const lastNavTargetRef = useRef<HTMLElement | null>(null);
183
+
184
+ // —— 滚动锚点跟踪:按钮只在「有可滚处」且「滚动中/悬停」时出现 ——
185
+ const [scrollAnchors, setScrollAnchors] = useState({ atTop: true, atBottom: true });
186
+ const [scrollActive, setScrollActive] = useState(false);
187
+ const [scrollBtnsHovered, setScrollBtnsHovered] = useState(false);
188
+ const [scrollTooltip, setScrollTooltip] = useState<TooltipKind | null>(null);
189
+ const tooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
190
+ const showScrollTooltip = useCallback((kind: TooltipKind) => {
191
+ if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current);
192
+ setScrollTooltip(kind);
193
+ tooltipTimerRef.current = setTimeout(() => setScrollTooltip(null), 1500);
194
+ }, []);
195
+ const hideScrollTooltip = useCallback(() => {
196
+ if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current);
197
+ tooltipTimerRef.current = null;
198
+ setScrollTooltip(null);
199
+ }, []);
200
+ const scrollIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
201
+
202
+ // ——「到最新」尾随窗口:smooth 滚动期间流式输出继续长高会滚不到底(目标
203
+ // scrollHeight 是调用瞬间的快照)。窗口期(2.5s)内每次内容/滚动变化都
204
+ // instant 钉底;到期或用户打断(wheel 上滚/触摸/向上键)后由 core follow
205
+ // 接管——贴底状态在 core SCROLL_BOTTOM_THRESHOLD=150 内,
206
+ // handleScrollPositionChange 会自动 userScrolledAway=false 重挂 follow,
207
+ // 与内置「到最新」语义一致。纯事件驱动,无轮询。——
208
+ // ——「到最早」钉顶窗口:镜像 tail-follow。revealHistory 全量展开后头部
209
+ // 仍有渲染/图片加载导致的 scrollHeight 渐增,窗口期内持续 scrollTo(0),
210
+ // 直到稳定或用户打断(wheel 下滚/触摸/向下键)。——
211
+ const tailFollowUntilRef = useRef(0);
212
+ const earliestFollowUntilRef = useRef(0);
213
+ // 导航代际:reveal 重跑 / sentinel 兑底 / 尾随钉底的延迟回调执行前检查,
214
+ // 用户已点别的导航则放弃,防慢回调与新点击竞争把视口拉走。
215
+ const navSeqRef = useRef(0);
216
+ const cancelTailFollow = useCallback(() => { tailFollowUntilRef.current = 0; }, []);
217
+ const cancelEarliestFollow = useCallback(() => { earliestFollowUntilRef.current = 0; }, []);
218
+
219
+ // —— 滚动锚点跟踪(端口 core ChatMinimap 的 ResizeObserver 双通道 +
220
+ // core handleScrollBoxRef 的「容器延迟挂载」对策):——
221
+ // · document capture 监听 scroll(scroll 不冒泡但捕获可达)→ 一次绑定,
222
+ // 容器何时挂载/替换都收得到(欢迎页 isEmptyNew 时 [data-chat-scroll]
223
+ // 不存在,首条消息后才出现——旧版挂载时绑一次会永久失灵);
224
+ // · MutationObserver(childList+subtree,rAF 合并)发现容器身份变化 →
225
+ // 重挂 RO + 立即重算;
226
+ // · ResizeObserver 观察容器 content-box(clientHeight/padding 变化,如
227
+ // bottomEyeRoom)+ 内容首元素(流式增长/懒加载变高)→ 内容变化不必等
228
+ // scroll 事件(内容增长不触发 scroll,core 注释原话)即刷新按钮显隐。
229
+ const trackedContainerRef = useRef<HTMLDivElement | null>(null);
230
+ const trackedContentRef = useRef<Element | null>(null);
231
+ const roRef = useRef<ResizeObserver | null>(null);
232
+ const recomputeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
233
+ const syncFrameRef = useRef<number | null>(null);
234
+
235
+ // —— hover 感知层:透明层常驻,按钮隐藏后指针停在该区域也能唤出 ——
236
+ const mousePosRef = useRef({ x: -9999, y: -9999 });
237
+ const toolbarRectRef = useRef<{ left: number; right: number; top: number; bottom: number } | null>(null);
238
+ useEffect(() => {
239
+ const onMouseMove = (e: MouseEvent) => {
240
+ mousePosRef.current = { x: e.clientX, y: e.clientY };
241
+ const r = toolbarRectRef.current;
242
+ if (!r) return;
243
+ const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
244
+ setScrollBtnsHovered(inside);
245
+ };
246
+ window.addEventListener("mousemove", onMouseMove);
247
+ return () => window.removeEventListener("mousemove", onMouseMove);
248
+ }, []);
249
+
250
+ // —— 常亮 toast + 会话内提示次数(每页刷新重置) ——
251
+ const [keepAwakeToast, setKeepAwakeToast] = useState(false);
252
+ const keepAwakeToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
253
+ const tooltipShowCountRef = useRef(0);
254
+ const keepAwakeToastCountRef = useRef(0);
255
+ const [keepAwake, setKeepAwake] = useState(false);
256
+ const wakeLockSupported = useWakeLock(keepAwake && agentRunning);
257
+
258
+ // —— 长按「到最新」切换常亮 ——
259
+ const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
260
+ const didLongPressRef = useRef(false);
261
+ const clearLongPress = useCallback(() => {
262
+ if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; }
263
+ }, []);
264
+
265
+ // —— 整条工具条拖拽换位(按下后 window 级全局监听,手指离开按钮列不中断)——
266
+ const onToolbarPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
267
+ if (e.button !== 0) return;
268
+ dragRef.current = { startX: e.clientX, startY: e.clientY, startY0: pos.y, moved: false, pointerId: e.pointerId };
269
+ dragModeRef.current = true;
270
+ const onMove = (ev: PointerEvent) => {
271
+ const drag = dragRef.current;
272
+ if (!drag || ev.pointerId !== drag.pointerId) return;
273
+ const dx = ev.clientX - drag.startX;
274
+ const dy = ev.clientY - drag.startY;
275
+ if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 8) return;
276
+ drag.moved = true;
277
+ try { ev.preventDefault(); } catch { /* ignore */ }
278
+ clearLongPress();
279
+ const el = toolbarRef.current;
280
+ if (!el) return;
281
+ const maxY = Math.max(8, window.innerHeight - el.offsetHeight - 8);
282
+ const ny = Math.min(Math.max(8, drag.startY0 + dy), maxY);
283
+ const nx = ev.clientX < window.innerWidth / 2 ? "left" : "right";
284
+ setPosAndRef({ align: nx, y: ny });
285
+ };
286
+ const onUp = () => {
287
+ const drag = dragRef.current;
288
+ if (!drag) return;
289
+ const wasDrag = drag.moved;
290
+ dragRef.current = null;
291
+ dragModeRef.current = false;
292
+ if (wasDrag) {
293
+ try { localStorage.setItem("pi-scroll-toolbar-pos", JSON.stringify({ align: posRef.current.align, y: posRef.current.y })); } catch { /* ignore */ }
294
+ }
295
+ window.removeEventListener("pointermove", onMove);
296
+ window.removeEventListener("pointerup", onUp);
297
+ window.removeEventListener("pointercancel", onUp);
298
+ };
299
+ window.addEventListener("pointermove", onMove, { passive: false });
300
+ window.addEventListener("pointerup", onUp);
301
+ window.addEventListener("pointercancel", onUp);
302
+ }, [pos.y, clearLongPress, setPosAndRef]);
303
+
304
+ // 纯状态重算(scroll / RO / 尾随补滚共用;不激活「滚动中」显示)。
305
+ const recomputeAnchors = useCallback(() => {
306
+ const c = trackedContainerRef.current;
307
+ if (!c || !c.isConnected) return;
308
+ const atTop = c.scrollTop <= SCROLL_ANCHOR_THRESHOLD;
309
+ const atBottom = c.scrollHeight - c.scrollTop - c.clientHeight <= SCROLL_ANCHOR_THRESHOLD;
310
+ setScrollAnchors((prev) => (prev.atTop === atTop && prev.atBottom === atBottom ? prev : { atTop, atBottom }));
311
+ if (Date.now() < tailFollowUntilRef.current) {
312
+ const dist = c.scrollHeight - c.scrollTop - c.clientHeight;
313
+ if (dist > 4) c.scrollTo({ top: c.scrollHeight, behavior: "auto" }); // instant 钉底,窗口到期/打断前持续跟随
314
+ }
315
+ if (Date.now() < earliestFollowUntilRef.current) {
316
+ // 钉顶:revealHistory 后头部渲染/图片加载使 scrollHeight 渐增,保持贴住最旧
317
+ if (c.scrollTop > 0) c.scrollTo({ top: 0, behavior: "auto" });
318
+ }
319
+ }, []);
320
+ // RO/重算节流(端口 minimap measureThrottle 150ms)。
321
+ const scheduleRecompute = useCallback(() => {
322
+ if (recomputeTimerRef.current) return;
323
+ recomputeTimerRef.current = setTimeout(() => {
324
+ recomputeTimerRef.current = null;
325
+ recomputeAnchors();
326
+ }, 120);
327
+ }, [recomputeAnchors]);
328
+
329
+ const observeContainer = useCallback((c: HTMLDivElement) => {
330
+ roRef.current?.disconnect();
331
+ // 换容器/重建观察时两个跟随窗口一并作废:旧容器上的钉顶/尾随对新人无意义,
332
+ // 否则切会话后残留窗口与 core follow 拔河最长 8s。
333
+ tailFollowUntilRef.current = 0;
334
+ earliestFollowUntilRef.current = 0;
335
+ const ro = new ResizeObserver(scheduleRecompute);
336
+ ro.observe(c); // 容器 content-box:clientHeight / paddingBottom(bottomEyeRoom)变化
337
+ trackedContainerRef.current = c;
338
+ trackedContentRef.current = null;
339
+ const content = c.firstElementChild;
340
+ if (content) {
341
+ ro.observe(content); // 内容元素:流式增长 / 懒加载变高
342
+ trackedContentRef.current = content;
343
+ }
344
+ roRef.current = ro;
345
+ recomputeAnchors();
346
+ }, [scheduleRecompute, recomputeAnchors]);
347
+
348
+ const syncContainer = useCallback(() => {
349
+ const next = document.querySelector<HTMLDivElement>("[data-chat-scroll]");
350
+ if (next && next === trackedContainerRef.current) {
351
+ // 防御:跟踪态但 RO 缺失(StrictMode 重跑窗口/异常态)→ 重建观察
352
+ if (!roRef.current) {
353
+ observeContainer(next);
354
+ return;
355
+ }
356
+ // 内容元素可能被 React 整体替换(切会话/重挂)——保持观察不脱靶
357
+ const content = next.firstElementChild;
358
+ if (content && content !== trackedContentRef.current) {
359
+ trackedContentRef.current = content;
360
+ roRef.current?.observe(content);
361
+ }
362
+ return;
363
+ }
364
+ if (!next && !trackedContainerRef.current) return;
365
+ if (next) {
366
+ observeContainer(next);
367
+ } else {
368
+ roRef.current?.disconnect();
369
+ roRef.current = null;
370
+ trackedContainerRef.current = null;
371
+ trackedContentRef.current = null;
372
+ tailFollowUntilRef.current = 0;
373
+ earliestFollowUntilRef.current = 0;
374
+ setScrollAnchors((prev) => (prev.atTop && prev.atBottom ? prev : { atTop: true, atBottom: true }));
375
+ }
376
+ }, [observeContainer]);
377
+
378
+ // 导航目标容器:跟踪缓存优先(由 scroll/RO/MO 维持新鲜),失联时兜底重发现。
379
+ const getContainer = useCallback((): HTMLDivElement | null => {
380
+ if (trackedContainerRef.current?.isConnected) return trackedContainerRef.current;
381
+ const next = document.querySelector<HTMLDivElement>("[data-chat-scroll]");
382
+ if (next && next !== trackedContainerRef.current) observeContainer(next);
383
+ return trackedContainerRef.current;
384
+ }, [observeContainer]);
385
+
386
+ const handleScrollEvent = useCallback((e: Event) => {
387
+ if (e.target !== trackedContainerRef.current) syncContainer(); // 容器替换/首次出现顺带发现
388
+ recomputeAnchors();
389
+ // 仅容器自身滚动激活「滚动中」显示:消息内代码块/minimap 等内层滚动体
390
+ // 的 scroll 事件(capture 可达)不应亮起导航条。
391
+ if (e.target === trackedContainerRef.current) {
392
+ setScrollActive(true);
393
+ if (scrollIdleTimerRef.current) clearTimeout(scrollIdleTimerRef.current);
394
+ scrollIdleTimerRef.current = setTimeout(() => setScrollActive(false), 2000);
395
+ }
396
+ }, [recomputeAnchors, syncContainer]);
397
+
398
+ useEffect(() => {
399
+ syncContainer();
400
+ const onScroll = (e: Event) => handleScrollEvent(e);
401
+ document.addEventListener("scroll", onScroll, { capture: true, passive: true });
402
+ const mo = new MutationObserver(() => {
403
+ // rAF 合并:childList 变更(新消息/工具块增减)每帧至多发现一次
404
+ if (syncFrameRef.current !== null) return;
405
+ syncFrameRef.current = requestAnimationFrame(() => {
406
+ syncFrameRef.current = null;
407
+ syncContainer();
408
+ });
409
+ });
410
+ mo.observe(document.body, { childList: true, subtree: true });
411
+ return () => {
412
+ document.removeEventListener("scroll", onScroll, { capture: true });
413
+ mo.disconnect();
414
+ roRef.current?.disconnect();
415
+ roRef.current = null;
416
+ // 跟踪态一并清空:StrictMode(dev 双挂载)/ effect 重跑后,syncContainer
417
+ // 走「未跟踪」分支重建 RO——只 disconnect 不清态会让重挂后的「已跟踪」
418
+ // 分支跳过重建,RO 通道静默死亡(scroll 通道正常,极难察觉)。
419
+ trackedContainerRef.current = null;
420
+ trackedContentRef.current = null;
421
+ if (recomputeTimerRef.current) { clearTimeout(recomputeTimerRef.current); recomputeTimerRef.current = null; }
422
+ if (syncFrameRef.current !== null) { cancelAnimationFrame(syncFrameRef.current); syncFrameRef.current = null; }
423
+ };
424
+ }, [handleScrollEvent, syncContainer]);
425
+
426
+ // 会话切换(容器 div 常被复用,身份不变):清两个跟随窗口 + 重发现容器,
427
+ // 防残留钉顶/尾随在新会话里与 core follow 拔河最长 8s。
428
+ const sessionId = props.sessionId;
429
+ const prevSessionIdRef = useRef(sessionId);
430
+ useEffect(() => {
431
+ if (prevSessionIdRef.current === sessionId) return;
432
+ prevSessionIdRef.current = sessionId;
433
+ tailFollowUntilRef.current = 0;
434
+ earliestFollowUntilRef.current = 0;
435
+ syncFrameRef.current !== null && (cancelAnimationFrame(syncFrameRef.current), syncFrameRef.current = null);
436
+ syncContainer();
437
+ }, [sessionId, syncContainer]);
438
+
439
+ // 两个跟随窗口的用户打断信号:wheel 上滚/向上键打断「到最新」,wheel 下滚/
440
+ // 向下键打断「到最早」,触摸两者都打断。判定粒度与 core markUserScrollIntent 一致。
441
+ useEffect(() => {
442
+ const onWheel = (e: WheelEvent) => {
443
+ if (e.deltaY < 0) cancelTailFollow();
444
+ else if (e.deltaY > 0) cancelEarliestFollow();
445
+ };
446
+ const onTouchStart = () => { cancelTailFollow(); cancelEarliestFollow(); };
447
+ const onKey = (e: KeyboardEvent) => {
448
+ if (e.key === "ArrowUp" || e.key === "PageUp" || e.key === "Home") cancelTailFollow();
449
+ if (e.key === "ArrowDown" || e.key === "PageDown" || e.key === "End") cancelEarliestFollow();
450
+ };
451
+ window.addEventListener("wheel", onWheel, { passive: true, capture: true });
452
+ window.addEventListener("touchstart", onTouchStart, { passive: true, capture: true });
453
+ window.addEventListener("keydown", onKey, { capture: true });
454
+ return () => {
455
+ window.removeEventListener("wheel", onWheel, { capture: true });
456
+ window.removeEventListener("touchstart", onTouchStart, { capture: true });
457
+ window.removeEventListener("keydown", onKey, { capture: true });
458
+ };
459
+ }, [cancelTailFollow, cancelEarliestFollow]);
460
+
461
+ // 卸载清理全部定时器
462
+ useEffect(() => () => {
463
+ if (scrollIdleTimerRef.current) clearTimeout(scrollIdleTimerRef.current);
464
+ if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current);
465
+ if (keepAwakeToastTimerRef.current) clearTimeout(keepAwakeToastTimerRef.current);
466
+ if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current);
467
+ }, []);
468
+
469
+ const showScrollButtons = (!scrollAnchors.atTop || !scrollAnchors.atBottom) && (scrollActive || (!isMobile && scrollBtnsHovered));
470
+
471
+ // —— 工具条 viewport rect 缓存(hover 感知用;位置/布局/显隐变化时重读)——
472
+ useEffect(() => {
473
+ const el = toolbarRef.current;
474
+ if (!el) return;
475
+ const read = () => {
476
+ const r = el.getBoundingClientRect();
477
+ toolbarRectRef.current = { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
478
+ const m = mousePosRef.current;
479
+ if (m.x >= r.left && m.x <= r.right && m.y >= r.top && m.y <= r.bottom) setScrollBtnsHovered(true);
480
+ };
481
+ read();
482
+ const onResize = () => {
483
+ const el2 = toolbarRef.current;
484
+ if (!el2) return;
485
+ const rr = el2.getBoundingClientRect();
486
+ toolbarRectRef.current = { left: rr.left, right: rr.right, top: rr.top, bottom: rr.bottom };
487
+ };
488
+ window.addEventListener("resize", onResize);
489
+ return () => window.removeEventListener("resize", onResize);
490
+ }, [pos.align, pos.y, isMobile, showScrollButtons]);
491
+
492
+ const scrollToEarliest = useCallback(() => {
493
+ const c = getContainer();
494
+ if (!c) return;
495
+ cancelTailFollow();
496
+ navSeqRef.current += 1; // 拦下 prev 的延迟重试(与「到最新」同待遇)
497
+ if (revealHistoryRef.current) {
498
+ // minimap 同款:一次性全量展开(已加载范围)+ 钉顶窗口盖住展开期间的
499
+ // scrollHeight 渐增。注:core 另有服务端分页(hasEarlierMessages),
500
+ // 若历史未拉全,需钉顶窗口与 sentinel 拉取的进/出变迁逐页收敛,
501
+ // 深史可能需再点一次续 8s。
502
+ earliestFollowUntilRef.current = Date.now() + 8000;
503
+ revealHistoryRef.current();
504
+ c.scrollTo({ top: 0, behavior: "auto" });
505
+ recomputeAnchors();
506
+ } else {
507
+ // 旧 core 兑底:滚到 0,容器顶部 sentinel 经 IntersectionObserver 逐页加载。
508
+ c.scrollTo({ top: 0, behavior: "smooth" });
509
+ }
510
+ }, [getContainer, cancelTailFollow, recomputeAnchors]);
511
+
512
+ const scrollToLatest = useCallback(() => {
513
+ const c = getContainer();
514
+ if (!c) return;
515
+ // 双向取消:earliest 钉顶窗口存续时点「到最新」不取消会顶底乒乓;
516
+ // 同时 bump 导航代际,拦下 prev 的延迟重试拽走尾随中的视口。
517
+ cancelEarliestFollow();
518
+ navSeqRef.current += 1;
519
+ // 开尾随窗口:smooth 期间流式输出继续长高时由 recomputeAnchors instant 追平。
520
+ tailFollowUntilRef.current = Date.now() + 2500;
521
+ c.scrollTo({ top: c.scrollHeight, behavior: "smooth" });
522
+ recomputeAnchors();
523
+ }, [getContainer, recomputeAnchors, cancelEarliestFollow]);
524
+
525
+ // —— 跳转后露出目标消息的操作菜单(mouseover 模拟,与 core 消息 hover 行为一致)——
526
+ const revealActionMenu = useCallback((el: HTMLElement) => {
527
+ try {
528
+ const menuTarget = (el.firstElementChild ?? el) as HTMLElement;
529
+ if (lastNavTargetRef.current && lastNavTargetRef.current !== menuTarget) {
530
+ lastNavTargetRef.current.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
531
+ }
532
+ menuTarget.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
533
+ lastNavTargetRef.current = menuTarget;
534
+ } catch { /* ignore */ }
535
+ }, []);
536
+
537
+ // —— prev/next 用户消息导航(端口自 core ScrollToolbar,改 DOM 发现)——
538
+ const scrollToUserMessageRef = useRef<((dir: -1 | 1) => void) | null>(null);
539
+ const prevRevealUsedRef = useRef(false);
540
+ const scrollToUserMessage = useCallback((dir: -1 | 1) => {
541
+ const seq = ++navSeqRef.current;
542
+ const c = getContainer();
543
+ if (!c) return;
544
+ cancelTailFollow();
545
+ cancelEarliestFollow(); // 显式导航优先;prev 的 reveal 路径下方会重新开钉顶窗口
546
+ const allEls = getMsgEls(c);
547
+ if (allEls.length === 0) return;
548
+ const userEls = getUserEls(c);
549
+
550
+ if (dir === -1) {
551
+ // prev:锚点 = 视口顶部之上/附近的最后一个用户消息(正在读的问题)。
552
+ // 它在视口内可见 → 跳它的前一条;在视口上方 → 直接跳到它。
553
+ let anchor = -1;
554
+ const limit = c.scrollTop + 8;
555
+ for (let i = 0; i < userEls.length; i++) {
556
+ const t0 = elTopInContainer(userEls[i], c);
557
+ if (t0 < 0) continue;
558
+ if (t0 <= limit + 4) { anchor = i; continue; }
559
+ if (anchor === -1) anchor = i;
560
+ break;
561
+ }
562
+ if (anchor === -1) anchor = userEls.length - 1;
563
+ const anchorEl = userEls[anchor];
564
+ const anchorTop = elTopInContainer(anchorEl, c);
565
+ const anchorVisible = anchorTop >= c.scrollTop;
566
+ const start = anchorVisible ? anchor - 1 : anchor;
567
+ for (let i = start; i >= 0; i--) {
568
+ const el = userEls[i];
569
+ if (!el || !el.isConnected) continue;
570
+ const t0 = elTopInContainer(el, c);
571
+ if (t0 < 0) continue;
572
+ c.scrollTo({ top: Math.max(0, t0 - 24), behavior: "smooth" });
573
+ revealActionMenu(el);
574
+ prevRevealUsedRef.current = false; // 跳转成功,下次窗口外仍可再 reveal
575
+ return;
576
+ }
577
+ } else {
578
+ // next:锚点 = 视口顶部处/下方第一条消息(任意角色),跳其后第一个用户消息。
579
+ let anchor = -1;
580
+ for (let i = 0; i < allEls.length; i++) {
581
+ const el = allEls[i];
582
+ if (!el || !el.isConnected) continue;
583
+ const t0 = elTopInContainer(el, c);
584
+ if (t0 < 0) continue;
585
+ if (t0 >= c.scrollTop) { anchor = i; break; }
586
+ }
587
+ if (anchor === -1) anchor = allEls.length - 1;
588
+ for (let i = anchor + 1; i < allEls.length; i++) {
589
+ const el = allEls[i];
590
+ if (!el || !el.isConnected || el.getAttribute("data-chat-msg-role") !== "user") continue;
591
+ const t0 = elTopInContainer(el, c);
592
+ if (t0 < 0) continue;
593
+ c.scrollTo({ top: Math.max(0, t0 - 24), behavior: "smooth" });
594
+ revealActionMenu(el);
595
+ return;
596
+ }
597
+ // 例外:后面没有更多用户输入了 —— 连点 next 应一路走到页面最末
598
+ // (最后一条消息的底部,通常是其后的 AI 回复末尾)。已在底部则停。
599
+ // 流式期间开短尾随窗口保证真贴底。
600
+ const distEnd = c.scrollHeight - c.scrollTop - c.clientHeight;
601
+ if (distEnd > 4) {
602
+ cancelEarliestFollow(); // 同「到最新」:先关钉顶窗口,防顶底乒乓
603
+ tailFollowUntilRef.current = Date.now() + 1500;
604
+ c.scrollTo({ top: c.scrollHeight, behavior: "smooth" });
605
+ recomputeAnchors();
606
+ }
607
+ }
608
+ // 渲染窗口内没有更早的用户消息了 —— 优先 minimap 同款:revealHistory 一次性
609
+ // 全量展开后重试(目标必在 DOM;重试仅一次,大会话渲染慢时不无限循环),
610
+ // 无该通道(旧 core)才退回「诱使 sentinel 重载」逐页兑底:sentinel 的
611
+ // IntersectionObserver 只在交叉状态变化时触发,已经滚在顶部不会重载,
612
+ // 所以要「离开顶部再回来」;每周期加载一页,最多 3 页。
613
+ if (dir === -1) {
614
+ if (revealHistoryRef.current && !prevRevealUsedRef.current) {
615
+ prevRevealUsedRef.current = true;
616
+ earliestFollowUntilRef.current = Date.now() + 8000;
617
+ revealHistoryRef.current();
618
+ // 双 rAF:等 React commit 全量渲染后再查找跳转(窗口已钉顶住渐增)
619
+ requestAnimationFrame(() => requestAnimationFrame(() => {
620
+ setTimeout(() => { if (navSeqRef.current === seq) scrollToUserMessageRef.current?.(-1); }, 60);
621
+ }));
622
+ return;
623
+ }
624
+ let attempts = 0;
625
+ const retry = async () => {
626
+ attempts += 1;
627
+ if (attempts > 3) return;
628
+ if (navSeqRef.current !== seq) return; // 用户已点别的导航,整个循环放弃
629
+ cancelEarliestFollow(); // 免得自家钉顶把 300px excursion 拉回,sentinel 观察不到进/出变迁
630
+ const c2 = getContainer();
631
+ if (!c2) return;
632
+ const h0 = c2.scrollHeight;
633
+ c2.scrollTo({ top: Math.min(c2.scrollHeight, 300), behavior: "auto" });
634
+ await new Promise((r) => setTimeout(r, 90));
635
+ c2.scrollTo({ top: 0, behavior: "auto" });
636
+ // 等 scrollHeight 变化(intersection 回调异步,页面预插后容器变高)
637
+ for (let i = 0; i < 20; i++) {
638
+ await new Promise((r) => setTimeout(r, 100));
639
+ if (navSeqRef.current !== seq) return; // 等待期间新导航,中断
640
+ if (i >= 2 && c2.scrollHeight !== h0) break;
641
+ }
642
+ if (getContainer()?.scrollHeight === h0) return; // 没加载出来(已到最早)
643
+ setTimeout(() => { if (navSeqRef.current === seq) scrollToUserMessageRef.current?.(-1); }, 120);
644
+ };
645
+ retry();
646
+ }
647
+ }, [revealActionMenu, getContainer, cancelTailFollow, cancelEarliestFollow, recomputeAnchors]);
648
+ scrollToUserMessageRef.current = scrollToUserMessage;
649
+
650
+ const onHover = useCallback((kind: TooltipKind) => (e: React.MouseEvent<HTMLButtonElement>) => {
651
+ if (tooltipShowCountRef.current < 3) {
652
+ tooltipShowCountRef.current += 1;
653
+ showScrollTooltip(kind);
654
+ }
655
+ e.currentTarget.style.background = "var(--bg-hover)";
656
+ e.currentTarget.style.color = "var(--text)";
657
+ }, [showScrollTooltip]);
658
+ const onHoverOut = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
659
+ hideScrollTooltip();
660
+ e.currentTarget.style.background = "color-mix(in srgb, var(--bg-panel) 92%, transparent)";
661
+ e.currentTarget.style.color = "var(--text-muted)";
662
+ }, [hideScrollTooltip]);
663
+
664
+ if (!showToolbar) return null;
665
+
666
+ const latestBtnStyle: React.CSSProperties = {
667
+ ...btnBase,
668
+ border: keepAwake ? "1px solid color-mix(in srgb, var(--accent) 55%, var(--border))" : "1px solid var(--border)",
669
+ background: keepAwake ? "color-mix(in srgb, var(--accent) 18%, var(--bg-panel))" : "color-mix(in srgb, var(--bg-panel) 92%, transparent)",
670
+ color: keepAwake ? "var(--accent)" : "var(--text-muted)",
671
+ boxShadow: "0 2px 8px rgba(15,23,42,0.22)",
672
+ };
673
+
674
+ return (
675
+ <div
676
+ ref={toolbarRef}
677
+ style={{
678
+ position: "absolute",
679
+ left: pos.align === "left" ? 12 : undefined,
680
+ right: pos.align === "right"
681
+ ? (isMobile ? 12 : `max(${CHAT_MINIMAP_WIDTH + 12}px, calc((100% - 820px) / 2 - 12px))`)
682
+ : undefined,
683
+ top: pos.y,
684
+ width: TOOLBAR_W,
685
+ // hover 感知层:常驻(按钮隐藏后仍可把指针停在原位唤出);pointerEvents:none 纯传感
686
+ minHeight: TOOLBAR_MIN_H,
687
+ zIndex: 30,
688
+ pointerEvents: "none",
689
+ touchAction: "none",
690
+ }}
691
+ >
692
+ {showScrollButtons && (
693
+ <div
694
+ onPointerDown={onToolbarPointerDown}
695
+ style={{
696
+ display: "flex",
697
+ flexDirection: "column",
698
+ gap: 10,
699
+ position: "relative",
700
+ zIndex: 30,
701
+ pointerEvents: "auto",
702
+ cursor: dragModeRef.current ? "grabbing" : "grab",
703
+ touchAction: "none",
704
+ }}
705
+ >
706
+ <button
707
+ aria-label="scrollToEarliest"
708
+ onClick={scrollToEarliest}
709
+ onMouseEnter={onHover("earliest")}
710
+ onMouseLeave={onHoverOut}
711
+ style={btnBase}
712
+ >
713
+ <ArrowUp />
714
+ </button>
715
+ <button
716
+ aria-label="scrollToPrevUser"
717
+ onClick={() => {
718
+ if (tapNavigatedRef.current) { tapNavigatedRef.current = false; return; }
719
+ scrollToUserMessage(-1);
720
+ }}
721
+ onPointerUp={() => {
722
+ const drag = dragRef.current;
723
+ if (drag && !drag.moved) {
724
+ tapNavigatedRef.current = true;
725
+ scrollToUserMessage(-1);
726
+ }
727
+ }}
728
+ onMouseEnter={onHover("prevUser")}
729
+ onMouseLeave={onHoverOut}
730
+ style={btnBase}
731
+ >
732
+ <PrevUserIcon />
733
+ </button>
734
+ <button
735
+ aria-label="scrollToNextUser"
736
+ onClick={() => {
737
+ if (tapNavigatedRef.current) { tapNavigatedRef.current = false; return; }
738
+ scrollToUserMessage(1);
739
+ }}
740
+ onPointerUp={() => {
741
+ const drag = dragRef.current;
742
+ if (drag && !drag.moved) {
743
+ tapNavigatedRef.current = true;
744
+ scrollToUserMessage(1);
745
+ }
746
+ }}
747
+ onMouseEnter={onHover("nextUser")}
748
+ onMouseLeave={onHoverOut}
749
+ style={btnBase}
750
+ >
751
+ <NextUserIcon />
752
+ </button>
753
+ {/* 60px 命中区包裹长按手势(视觉按钮仍 44px 居中) */}
754
+ <div
755
+ style={{
756
+ pointerEvents: "auto",
757
+ display: "flex",
758
+ alignItems: "center",
759
+ justifyContent: "center",
760
+ width: 60,
761
+ height: 60,
762
+ margin: -8,
763
+ userSelect: "none",
764
+ WebkitUserSelect: "none",
765
+ }}
766
+ onPointerDown={(e) => {
767
+ // 长按(>600ms)切换常亮;短按仍跳到最新。NOTE:不能 preventDefault
768
+ //(移动端会吞掉后续 click)。stopPropagation:长按时手指微动不触发拖拽。
769
+ e.stopPropagation();
770
+ clearLongPress();
771
+ if (!keepAwakeFeature || !wakeLockSupported) return;
772
+ longPressTimerRef.current = setTimeout(() => {
773
+ didLongPressRef.current = true;
774
+ const nowOn = !keepAwake;
775
+ setKeepAwake(nowOn);
776
+ document.getSelection()?.removeAllRanges();
777
+ const prev = document.body.style.userSelect;
778
+ document.body.style.userSelect = "none";
779
+ if (nowOn && keepAwakeToastCountRef.current < 3) {
780
+ keepAwakeToastCountRef.current += 1;
781
+ setKeepAwakeToast(true);
782
+ }
783
+ if (keepAwakeToastTimerRef.current) clearTimeout(keepAwakeToastTimerRef.current);
784
+ keepAwakeToastTimerRef.current = setTimeout(() => {
785
+ setKeepAwakeToast(false);
786
+ document.body.style.userSelect = prev;
787
+ }, 2000);
788
+ }, 600);
789
+ }}
790
+ onPointerUp={clearLongPress}
791
+ onPointerCancel={clearLongPress}
792
+ onPointerLeave={clearLongPress}
793
+ onContextMenu={(e) => e.preventDefault()}
794
+ >
795
+ <button
796
+ aria-label="scrollToLatest"
797
+ onClick={() => {
798
+ if (didLongPressRef.current) { didLongPressRef.current = false; return; }
799
+ scrollToLatest();
800
+ }}
801
+ onMouseEnter={(e) => {
802
+ if (tooltipShowCountRef.current < 3) {
803
+ tooltipShowCountRef.current += 1;
804
+ showScrollTooltip("latest");
805
+ }
806
+ e.currentTarget.style.background = keepAwake
807
+ ? "color-mix(in srgb, var(--accent) 26%, var(--bg-panel))"
808
+ : "var(--bg-hover)";
809
+ e.currentTarget.style.color = keepAwake ? "var(--accent)" : "var(--text)";
810
+ }}
811
+ onMouseLeave={(e) => {
812
+ hideScrollTooltip();
813
+ e.currentTarget.style.background = keepAwake
814
+ ? "color-mix(in srgb, var(--accent) 18%, var(--bg-panel))"
815
+ : "color-mix(in srgb, var(--bg-panel) 92%, transparent)";
816
+ e.currentTarget.style.color = keepAwake ? "var(--accent)" : "var(--text-muted)";
817
+ }}
818
+ style={latestBtnStyle}
819
+ >
820
+ <ArrowDown />
821
+ </button>
822
+ </div>
823
+ </div>
824
+ )}
825
+ {scrollTooltip && (
826
+ <div
827
+ style={{
828
+ position: "absolute",
829
+ right: "calc(100% + 10px)",
830
+ top: tooltipTop[scrollTooltip],
831
+ whiteSpace: "nowrap",
832
+ fontSize: 12,
833
+ color: "var(--text)",
834
+ background: "color-mix(in srgb, var(--bg-panel) 96%, transparent)",
835
+ border: "1px solid var(--border)",
836
+ borderRadius: 6,
837
+ padding: "4px 8px",
838
+ boxShadow: "0 2px 10px rgba(15,23,42,0.25)",
839
+ pointerEvents: "none",
840
+ zIndex: 31,
841
+ }}
842
+ >
843
+ {t(scrollTooltip === "latest" ? "nav.latestHint" : `nav.${scrollTooltip}`)}
844
+ </div>
845
+ )}
846
+ {keepAwakeToast && (
847
+ <div
848
+ style={{
849
+ position: "absolute",
850
+ bottom: 12,
851
+ left: "50%",
852
+ transform: "translateX(-50%)",
853
+ zIndex: 40,
854
+ fontSize: 13,
855
+ color: "var(--text)",
856
+ background: "color-mix(in srgb, var(--bg-panel) 96%, transparent)",
857
+ border: "1px solid color-mix(in srgb, var(--accent) 50%, var(--border))",
858
+ borderRadius: 8,
859
+ padding: "6px 14px",
860
+ boxShadow: "0 2px 12px rgba(15,23,42,0.25)",
861
+ pointerEvents: "none",
862
+ textAlign: "center",
863
+ maxWidth: "min(80vw, 480px)",
864
+ }}
865
+ >
866
+ {t("nav.keepAwakeOn")}
867
+ </div>
868
+ )}
869
+ </div>
870
+ );
871
+ }