From 241b9b01a73d0dcb4619ba50b77f4864e59096e9 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sun, 19 Jul 2026 22:48:47 +0800 Subject: [PATCH] feat(webui): mirror the floor navigation rail with touch adaptations Port chat-floor-nav (floorModel/floorBookmarks) and FloorNavRail to the gateway WebUI and register all three files in the mirror manifest. floorModel now takes a minimal FloorSourceItem shape so the same bytes serve desktop RenderTimelineItem and web TranscriptRow inputs; FloorNavRail's bottomReservePx becomes a bottomOffset CSS string so the web end can track the composer overlay height variable. GatewayTranscript grows a navRef jump handle (scrollToIndex + framed re-align convergence) and viewport-top anchor reporting mirroring the desktop TranscriptList logic; GatewayApp derives floors, renders the rail, and breaks scroll-follow before jumping. Touch (coarse pointer) adaptations, shared by both ends: - rail hidden at rest, revealed while scrolling, fades out 1.4s after scrolling stops (hidden state drops pointer events) - collapsed marker tap expands the panel instead of jumping; jumping from the panel collapses it; outside tap dismisses - marker cap tightened to 12 on touch so tall phone viewports no longer fill the whole edge with markers; pin buttons always visible Also nudge the rail from right-2 to right-4 clear of the scrollbar. Co-Authored-By: Claude Fable 5 --- .../agent-gateway/web/src/app/GatewayApp.tsx | 28 +- .../web/src/components/GatewayTranscript.tsx | 140 +++++++ .../src/lib/chat-floor-nav/floorBookmarks.ts | 108 +++++ .../web/src/lib/chat-floor-nav/floorModel.ts | 99 +++++ .../pages/chat/transcript/FloorNavRail.tsx | 370 ++++++++++++++++++ .../agent-gateway/web/test/floor-nav.test.mjs | 168 ++++++++ .../src/lib/chat-floor-nav/floorModel.ts | 21 +- .../pages/chat/transcript/ChatTranscript.tsx | 3 +- .../pages/chat/transcript/FloorNavRail.tsx | 170 +++++++- scripts/mirror-manifest.json | 3 + 10 files changed, 1088 insertions(+), 22 deletions(-) create mode 100644 crates/agent-gateway/web/src/lib/chat-floor-nav/floorBookmarks.ts create mode 100644 crates/agent-gateway/web/src/lib/chat-floor-nav/floorModel.ts create mode 100644 crates/agent-gateway/web/src/pages/chat/transcript/FloorNavRail.tsx create mode 100644 crates/agent-gateway/web/test/floor-nav.test.mjs diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index fcfb34e98..85f7231c5 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -119,7 +119,8 @@ function isLocalDraftConversationId(id: string) { } import { HistoryShareModal } from "@/components/chat/HistoryShareModal"; -import { GatewayTranscript } from "@/components/GatewayTranscript"; +import { GatewayTranscript, type GatewayTranscriptNavHandle } from "@/components/GatewayTranscript"; +import { buildFloorEntries } from "@/lib/chat-floor-nav/floorModel"; import { useScrollFollow } from "@/lib/chat-scroll/useScrollFollow"; import { parseHistoryShareToken } from "@/lib/historyShare"; import { @@ -136,6 +137,7 @@ import { normalizeRunningConversationItems, } from "@/lib/sidebar/webSidebarBackend"; import { findWorkspaceProject, mergeWorkspaceProjectsWithHistory } from "@/lib/workspaceProjects"; +import { FloorNavRail } from "@/pages/chat/transcript/FloorNavRail"; import { LoginPage } from "@/pages/LoginPage"; import { SettingsSyncLoading } from "@/pages/SettingsSyncLoading"; import { SharedHistoryPage } from "@/pages/SharedHistoryPage"; @@ -309,6 +311,17 @@ export default function GatewayApp() { listenerRoot: transcriptScrollAreaRoot, trackKeys: true, }); + // 楼层导航:当前楼层由转写区上报,跳转经 navRef 直达虚拟列表;粘底跟随 + // 激活时程序化滚动会被立即拽回底部——跳转前先按「跳入历史」语义解除跟随。 + const transcriptNavRef = useRef(null); + const [activeFloorKey, setActiveFloorKey] = useState(null); + const handleFloorJump = useCallback( + (rowKey: string) => { + transcriptFollow.breakFollow(); + transcriptNavRef.current?.scrollToRowKey(rowKey); + }, + [transcriptFollow], + ); const composerRef = useRef(null); const composerDraftCacheRef = useRef>(new Map()); const composerDraftOwnerRef = useRef(""); @@ -3789,6 +3802,7 @@ export default function GatewayApp() { }, [selectedHistoryId, sidebarConversationsById]); const transcriptRows = displayedTranscript.rows; const transcriptLiveStartIndex = displayedTranscript.liveStartIndex; + const transcriptFloors = useMemo(() => buildFloorEntries(transcriptRows), [transcriptRows]); // Row count gates everything visual (empty state, error banner, loading // screen): entryCount can be non-zero while nothing renders (meta-only // entries), and hiding an error behind an invisible entry would strand it. @@ -4184,6 +4198,8 @@ export default function GatewayApp() { liveStartIndex={transcriptLiveStartIndex} activeTurnKey={displayedTranscript.activeTurnKey} isViewportFollowing={transcriptFollow.isFollowing} + navRef={transcriptNavRef} + onAnchorUserRowChange={setActiveFloorKey} error={transcriptError} toolStatus={transcriptToolStatus} toolStatusIsCompaction={transcriptToolStatusIsCompaction} @@ -4210,6 +4226,16 @@ export default function GatewayApp() { suggestionsDisabled={isSuggestionTyping} /> + {displayedTranscriptRowCount > 0 && !conversationOpenState.showOverlay ? ( + + ) : null} {conversationOpenState.showOverlay ? ( ) : null} diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index abf7fa88f..3b06e3200 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -1,6 +1,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { type Dispatch, + type MutableRefObject, memo, type SetStateAction, useCallback, @@ -85,6 +86,10 @@ type GatewayTranscriptProps = { // Whether the scroll-follow engine is attached to the bottom; gates the // virtualizer's resize-compensation carve-out for live-row growth. isViewportFollowing?: () => boolean; + // Imperative jump handle for the floor navigation rail. + navRef?: MutableRefObject; + // Reports the user row at the viewport's top edge (the "current floor"). + onAnchorUserRowChange?: (rowKey: string | null) => void; error?: string | null; toolStatus?: string | null; toolStatusIsCompaction?: boolean; @@ -125,6 +130,13 @@ function rowRenderMode(row: Extract) { return row.origin === "stream" ? ("streaming" as const) : ("static" as const); } +export type GatewayTranscriptNavHandle = { + // Aligns the row to the viewport top and keeps re-aligning for a few + // frames while dynamic measurements land (convergent, cancelled by user + // scroll input). + scrollToRowKey: (rowKey: string) => void; +}; + const TRANSCRIPT_ROW_ESTIMATED_HEIGHT = 260; const TRANSCRIPT_ROW_GAP = 18; const TRANSCRIPT_ROW_OVERSCAN_COUNT = 5; @@ -1150,6 +1162,8 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr activeTurnKey?: string | null; scrollViewport: HTMLDivElement | null; isViewportFollowing?: () => boolean; + navRef?: MutableRefObject; + onAnchorUserRowChange?: (rowKey: string | null) => void; hasMoreHistory?: boolean; isLoadingMoreHistory?: boolean; onLoadFullHistory?: () => void; @@ -1179,6 +1193,8 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr activeTurnKey, scrollViewport, isViewportFollowing, + navRef, + onAnchorUserRowChange, hasMoreHistory, isLoadingMoreHistory, onLoadFullHistory, @@ -1340,6 +1356,126 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr isFollowing: () => isViewportFollowing?.() ?? false, }); + // 楼层跳转:scrollToIndex(align:"start") 后连续几帧重对齐——目标行远处的 + // 估高行在滚动后被真实测量,落点会漂移;对准同一 index 是收敛操作,不会 + // 震荡。收敛期间用户的滚轮/触摸/按键立即取消收敛;新跳转替换旧收敛。 + const virtualItemsRef = useRef(virtualItems); + virtualItemsRef.current = virtualItems; + const cancelJumpSettleRef = useRef<() => void>(() => {}); + useLayoutEffect(() => { + if (!navRef) return; + const handle: GatewayTranscriptNavHandle = { + scrollToRowKey: (rowKey) => { + cancelJumpSettleRef.current(); + const alignToRow = () => { + const index = virtualItemsRef.current.findIndex((item) => item.key === rowKey); + if (index < 0) return false; + transcriptVirtualizer.scrollToIndex(index, { align: "start" }); + return true; + }; + if (!alignToRow()) return; + let rafId: number | null = null; + const stopSettle = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + scrollViewport?.removeEventListener("wheel", stopSettle); + scrollViewport?.removeEventListener("touchstart", stopSettle); + scrollViewport?.removeEventListener("keydown", stopSettle); + if (cancelJumpSettleRef.current === stopSettle) { + cancelJumpSettleRef.current = () => {}; + } + }; + cancelJumpSettleRef.current = stopSettle; + scrollViewport?.addEventListener("wheel", stopSettle, { passive: true }); + scrollViewport?.addEventListener("touchstart", stopSettle, { passive: true }); + scrollViewport?.addEventListener("keydown", stopSettle); + let remainingFrames = 6; + const settle = () => { + rafId = null; + if (!alignToRow()) { + stopSettle(); + return; + } + remainingFrames -= 1; + if (remainingFrames > 0) { + rafId = requestAnimationFrame(settle); + } else { + stopSettle(); + } + }; + rafId = requestAnimationFrame(settle); + }, + }; + navRef.current = handle; + return () => { + cancelJumpSettleRef.current(); + if (navRef.current === handle) { + navRef.current = null; + } + }; + }, [navRef, transcriptVirtualizer, scrollViewport]); + + // 楼层导航当前楼层:以「视口顶缘(+8px 容差)」所落在的用户消息为准——与 + // 跳转的 align:"start" 落位一致,跳转后高亮的必然是刚点的楼层;视口贴近 + // 内容底部时直接取最后一层(否则短对话拼满一屏时底部楼层永远无法成为当前 + // 层)。贴底判定用 scrollHeight(与 scrollTop/clientHeight 同一坐标系, + // 含底部保留区),避免与 getTotalSize 的列表局部坐标错位。 + const lastAnchorRef = useRef(null); + const onAnchorUserRowChangeRef = useRef(onAnchorUserRowChange); + onAnchorUserRowChangeRef.current = onAnchorUserRowChange; + const reportAnchorRef = useRef(() => {}); + reportAnchorRef.current = () => { + const callback = onAnchorUserRowChangeRef.current; + if (!callback || !scrollViewport) return; + const itemList = virtualItemsRef.current; + let anchorKey: string | null = null; + if (itemList.length > 0) { + const scrollTop = scrollViewport.scrollTop; + const viewportHeight = scrollViewport.clientHeight; + const nearBottom = scrollTop + viewportHeight >= scrollViewport.scrollHeight - 32; + let anchorIndex = -1; + if (nearBottom) { + anchorIndex = itemList.length - 1; + } else { + const anchorLine = scrollTop + 8; + const items = transcriptVirtualizer.getVirtualItems(); + for (const item of items) { + if (item.start > anchorLine) break; + anchorIndex = item.index; + } + if (anchorIndex === -1) anchorIndex = items[0]?.index ?? -1; + } + for (let i = Math.min(anchorIndex, itemList.length - 1); i >= 0; i--) { + const item = itemList[i]; + if (item?.kind === "row" && item.row.kind === "user") { + anchorKey = item.row.key; + break; + } + } + } + if (anchorKey !== lastAnchorRef.current) { + lastAnchorRef.current = anchorKey; + callback(anchorKey); + } + }; + + useEffect(() => { + if (!scrollViewport) return; + const handler = () => reportAnchorRef.current(); + handler(); + scrollViewport.addEventListener("scroll", handler, { passive: true }); + return () => scrollViewport.removeEventListener("scroll", handler); + }, [scrollViewport]); + + // 行集合变化(消息追加、流式落定)后兜底重算一次;依赖 virtualItems 而不是 + // 每次渲染都跑,避免「上报 → 父级重渲染 → 再上报」的空转循环。 + useEffect(() => { + virtualItemsRef.current = virtualItems; + reportAnchorRef.current(); + }, [virtualItems]); + // First paint of a conversation lands at the bottom before the user sees // anything: scrollToEnd re-targets as dynamic measurements land. The region // remounts per conversation (keyed by the parent), so this runs once per @@ -1569,6 +1705,8 @@ export function GatewayTranscript({ liveStartIndex = -1, activeTurnKey = null, isViewportFollowing, + navRef, + onAnchorUserRowChange, error, toolStatus, toolStatusIsCompaction = false, @@ -1652,6 +1790,8 @@ export function GatewayTranscript({ activeTurnKey={activeTurnKey} scrollViewport={transcriptScrollViewport} isViewportFollowing={isViewportFollowing} + navRef={navRef} + onAnchorUserRowChange={onAnchorUserRowChange} hasMoreHistory={hasMoreHistory} isLoadingMoreHistory={isLoadingMoreHistory} onLoadFullHistory={onLoadFullHistory} diff --git a/crates/agent-gateway/web/src/lib/chat-floor-nav/floorBookmarks.ts b/crates/agent-gateway/web/src/lib/chat-floor-nav/floorBookmarks.ts new file mode 100644 index 000000000..81b87d372 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat-floor-nav/floorBookmarks.ts @@ -0,0 +1,108 @@ +// 楼层收藏的前端持久化:单个版本化 localStorage 键(与 lib/settings/storage.ts +// 的 JSON blob 惯例一致),结构 { version, conversations: { [conversationId]: +// messageId[] } }。收藏按稳定消息 id(`user-${uuid}`,随会话存 SQLite)记录, +// 因此重启后仍能对上。localStorage 不可用时收藏静默降级为仅本次运行有效。 + +const STORAGE_KEY = "liveagent.floor-bookmarks.v1"; +/** 防止无限增长:仅保留最近写入的这么多个会话的收藏。 */ +const MAX_CONVERSATIONS = 200; + +const EMPTY_BOOKMARKS: ReadonlySet = new Set(); + +let cache: Map> | null = null; +const listeners = new Set<() => void>(); + +function readStoredConversations(): Record { + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return {}; + const conversations = (parsed as { conversations?: unknown }).conversations; + if (!conversations || typeof conversations !== "object") return {}; + const result: Record = {}; + for (const [conversationId, ids] of Object.entries(conversations as Record)) { + if (!Array.isArray(ids)) continue; + const clean = ids.filter((id): id is string => typeof id === "string" && id.length > 0); + if (clean.length > 0) result[conversationId] = clean; + } + return result; + } catch { + return {}; + } +} + +function ensureCache(): Map> { + if (!cache) { + cache = new Map( + Object.entries(readStoredConversations()).map(([conversationId, ids]) => [ + conversationId, + new Set(ids) as ReadonlySet, + ]), + ); + } + return cache; +} + +function persist(map: Map>) { + // 容量裁剪直接作用在内存 Map 上(Map 迭代序 = 插入序,头部最旧), + // 再整体落盘——内存与 localStorage 永远一致,不会出现「本次运行还能看到 + // 已被淘汰会话的收藏、重启后凭空消失」的分叉。 + while (map.size > MAX_CONVERSATIONS) { + const oldest = map.keys().next().value; + if (oldest === undefined) break; + map.delete(oldest); + } + try { + const payload = { + version: 1, + conversations: Object.fromEntries([...map.entries()].map(([id, ids]) => [id, [...ids]])), + }; + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch { + // 存储不可用(隐私模式/配额):收藏仅在本次运行内生效。 + } +} + +function emit() { + for (const listener of listeners) { + listener(); + } +} + +/** 返回某会话的收藏集合;未变更时引用稳定,可直接用于 useSyncExternalStore。 */ +export function getFloorBookmarks(conversationId: string): ReadonlySet { + return ensureCache().get(conversationId) ?? EMPTY_BOOKMARKS; +} + +export function toggleFloorBookmark(conversationId: string, messageId: string): void { + if (!conversationId || !messageId) return; + const map = ensureCache(); + const next = new Set(map.get(conversationId) ?? []); + if (next.has(messageId)) { + next.delete(messageId); + } else { + next.add(messageId); + } + if (next.size === 0) { + map.delete(conversationId); + } else { + // 重新插入让该会话回到 Map 尾部(persist 的容量裁剪保最近使用)。 + map.delete(conversationId); + map.set(conversationId, next); + } + persist(map); + emit(); +} + +export function subscribeFloorBookmarks(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** 仅供测试:清空内存缓存,强制下次访问重读 localStorage。 */ +export function resetFloorBookmarksCacheForTest(): void { + cache = null; +} diff --git a/crates/agent-gateway/web/src/lib/chat-floor-nav/floorModel.ts b/crates/agent-gateway/web/src/lib/chat-floor-nav/floorModel.ts new file mode 100644 index 000000000..e0e813d6d --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat-floor-nav/floorModel.ts @@ -0,0 +1,99 @@ +/** 楼层导航条目:一条用户发送的消息。 */ +export type FloorEntry = { + /** 虚拟列表行 key(与行模型的用户行 key 一致),用于跳转定位。 */ + rowKey: string; + /** 稳定消息 id(持久化于 SQLite,重启不变),用于收藏。 */ + messageId: string; + /** 消息开头若干字符,空白折叠后截断。 */ + preview: string; +}; + +/** + * 楼层来源行的最小结构:桌面端渲染时间线(RenderTimelineItem)与 WebUI 转写 + * 行(TranscriptRow)都满足此形状,本模块因此可在两端字节级镜像。 + */ +export type FloorSourceItem = { + kind: string; + key: string; + text?: string; + messageRef?: { messageId: string }; +}; + +const PREVIEW_MAX_CHARS = 24; + +export function buildFloorPreview(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (!collapsed) return "…"; + // 按码点截断(Array.from 迭代码点),避免把 emoji 等代理对从中间劈开。 + const chars = Array.from(collapsed); + return chars.length > PREVIEW_MAX_CHARS + ? `${chars.slice(0, PREVIEW_MAX_CHARS).join("")}…` + : collapsed; +} + +/** + * 从渲染行列表派生楼层列表。只保留 kind === "user" 的条目——工具调用/返回 + * 折叠在 assistant 组内、系统提示词不在时间线上,因此天然只剩用户消息。 + */ +export function buildFloorEntries(items: readonly FloorSourceItem[]): FloorEntry[] { + const entries: FloorEntry[] = []; + for (const item of items) { + if (item.kind !== "user") continue; + entries.push({ + rowKey: item.key, + messageId: item.messageRef?.messageId ?? item.key, + preview: buildFloorPreview(item.text ?? ""), + }); + } + return entries; +} + +/** + * 收起态短横线的均匀采样:楼层数超过上限时等距取 maxMarkers 个(含首尾), + * mustKeep(收藏楼层)始终保留。取样按「均分索引」而不是固定步长,楼层数 + * 越过上限时标记数连续过渡(n→n+1 不会出现数量骤减)。 + * + * 注意:当前楼层不参与 mustKeep——滚动中强插/移除会让整列标记抖动;调用方 + * 应改用 resolveNearestSampledRowKey 把高亮落在最近的已采样标记上。 + */ +export function sampleFloorEntries( + floors: FloorEntry[], + maxMarkers: number, + mustKeepRowKeys: ReadonlySet, +): FloorEntry[] { + if (maxMarkers <= 0) return []; + if (floors.length <= maxMarkers) return floors; + const picked = new Set(); + const lastIndex = floors.length - 1; + for (let i = 0; i < maxMarkers; i++) { + picked.add(Math.round((i * lastIndex) / (maxMarkers - 1 || 1))); + } + return floors.filter((floor, index) => picked.has(index) || mustKeepRowKeys.has(floor.rowKey)); +} + +/** + * 在采样后的标记里找到与当前楼层最近的一个(按原始楼层序距离),让高亮 + * 始终有落点且不改变采样集合本身。 + */ +export function resolveNearestSampledRowKey( + floors: FloorEntry[], + sampled: FloorEntry[], + activeRowKey: string | null, +): string | null { + if (!activeRowKey || sampled.length === 0) return null; + if (sampled.some((floor) => floor.rowKey === activeRowKey)) return activeRowKey; + const activeIndex = floors.findIndex((floor) => floor.rowKey === activeRowKey); + if (activeIndex === -1) return null; + let nearest: string | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const marker of sampled) { + const markerIndex = floors.findIndex((floor) => floor.rowKey === marker.rowKey); + if (markerIndex === -1) continue; + const distance = Math.abs(markerIndex - activeIndex); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = marker.rowKey; + } + } + return nearest; +} diff --git a/crates/agent-gateway/web/src/pages/chat/transcript/FloorNavRail.tsx b/crates/agent-gateway/web/src/pages/chat/transcript/FloorNavRail.tsx new file mode 100644 index 000000000..e3f160a0b --- /dev/null +++ b/crates/agent-gateway/web/src/pages/chat/transcript/FloorNavRail.tsx @@ -0,0 +1,370 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import { Pin } from "../../../components/icons"; +import { useLocale } from "../../../i18n"; +import { + getFloorBookmarks, + subscribeFloorBookmarks, + toggleFloorBookmark, +} from "../../../lib/chat-floor-nav/floorBookmarks"; +import { + type FloorEntry, + resolveNearestSampledRowKey, + sampleFloorEntries, +} from "../../../lib/chat-floor-nav/floorModel"; +import { cn } from "../../../lib/shared/utils"; + +/** 收起态短横线数量上限的绝对边界(实际数量随可用高度自适应)。 */ +const MIN_COLLAPSED_MARKERS = 8; +const MAX_COLLAPSED_MARKERS = 40; +/** + * 触屏端收起态上限单独收紧:手机视口高而窄,高度自适应会直接摸到桌面上限, + * 超长会话下整列标记撑满全屏高度、视觉噪音大;压成短列后配合 nav 的垂直 + * 居中布局只占屏幕中段一小截。楼层再多也只是采样更稀,首尾仍然保留。 + */ +const MAX_COLLAPSED_MARKERS_TOUCH = 12; +/** 单根短横线(2.5px)+ 间距(7px)的占位高度。 */ +const MARKER_SLOT_PX = 9.5; +/** 鼠标移出后延迟收起,避免指针在轨道与面板间移动时闪烁。 */ +const COLLAPSE_DELAY_MS = 160; +/** 触屏端:滚动停止后导航栏保持可见的时长,随后淡出避免遮挡内容。 */ +const TOUCH_SCROLL_REVEAL_MS = 1400; + +function useFloorBookmarks(conversationId: string): ReadonlySet { + const getSnapshot = useCallback(() => getFloorBookmarks(conversationId), [conversationId]); + return useSyncExternalStore(subscribeFloorBookmarks, getSnapshot, getSnapshot); +} + +export function FloorNavRail(props: { + conversationId: string; + floors: FloorEntry[]; + activeRowKey: string | null; + /** + * 导航栏底缘的 CSS 偏移(避开底部输入框悬浮区)。桌面端传计算好的像素值 + * (如 "196px"),WebUI 传 CSS 变量表达式(如 "calc(var(--x) + 12px)")。 + */ + bottomOffset?: string; + /** + * 转写滚动视口。触屏端用于「滚动时显现、静止后淡出」——不传则触屏端也 + * 常显(桌面端 hover 交互不依赖此元素)。 + */ + scrollViewport?: HTMLElement | null; + onJump: (rowKey: string) => void; +}) { + const { + conversationId, + floors, + activeRowKey, + bottomOffset = "8px", + scrollViewport = null, + onJump, + } = props; + const { locale } = useLocale(); + const isEn = locale === "en-US"; + const bookmarks = useFloorBookmarks(conversationId); + const [expanded, setExpanded] = useState(false); + const collapseTimerRef = useRef(null); + const panelScrollRef = useRef(null); + // nav 元素走 callback ref → state(与 ChatTranscript 绑定 scrollViewport 同一 + // 模式):楼层 <2 时 rail 渲染为 null,nav 在组件已挂载后才出现/消失,一次性 + // 挂载 effect 会错过它——按元素身份重跑,观察器才始终挂在活着的节点上。 + const [navEl, setNavEl] = useState(null); + + // 触屏(无 hover)环境:展开/收起改由点按驱动,跳转后主动收起面板。 + const isCoarsePointer = useMemo( + () => + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(hover: none), (pointer: coarse)").matches, + [], + ); + + // 触屏端滚动显隐:平时整体隐藏不遮内容,滚动中显现、静止一段时间后淡出。 + // 面板展开期间不淡出(用户正在交互);隐藏态关闭指针事件,透传给转写区。 + const [touchRevealed, setTouchRevealed] = useState(false); + const revealTimerRef = useRef(null); + const expandedRef = useRef(false); + useEffect(() => { + if (!isCoarsePointer || !scrollViewport) return; + const handleScroll = () => { + setTouchRevealed(true); + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + } + revealTimerRef.current = window.setTimeout(() => { + revealTimerRef.current = null; + // 面板展开中不淡出;面板收起时(handleLeave/外点)会重新走到这里。 + if (!expandedRef.current) setTouchRevealed(false); + }, TOUCH_SCROLL_REVEAL_MS); + }; + scrollViewport.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + scrollViewport.removeEventListener("scroll", handleScroll); + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + }; + }, [isCoarsePointer, scrollViewport]); + + // 收起态标记数随聊天区可用高度自适应:矮视口(小窗口/高输入框)少放几根, + // 保证最新楼层的标记不被裁掉。触屏端上限另行收紧(见常量注释)。 + const maxMarkers = isCoarsePointer ? MAX_COLLAPSED_MARKERS_TOUCH : MAX_COLLAPSED_MARKERS; + const [markerBudget, setMarkerBudget] = useState(maxMarkers); + useLayoutEffect(() => { + if (!navEl || typeof ResizeObserver === "undefined") return; + const update = () => { + const budget = Math.floor((navEl.clientHeight - 24) / MARKER_SLOT_PX); + setMarkerBudget(Math.max(MIN_COLLAPSED_MARKERS, Math.min(maxMarkers, budget))); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(navEl); + return () => observer.disconnect(); + }, [navEl, maxMarkers]); + + // 展开时把当前楼层滚到面板中间,楼层很多时不必从头找。 + useLayoutEffect(() => { + if (!expanded) return; + panelScrollRef.current + ?.querySelector('[data-floor-active="true"]') + ?.scrollIntoView({ block: "center" }); + }, [expanded]); + + // 触屏自动隐藏仅在提供了滚动视口时启用。 + const touchAutoHide = isCoarsePointer && scrollViewport !== null; + + // 面板展开期间强制可见并挂起淡出计时;收起后重新计时淡出。 + useEffect(() => { + expandedRef.current = expanded; + if (!touchAutoHide) return; + if (expanded) { + setTouchRevealed(true); + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + return; + } + revealTimerRef.current = window.setTimeout(() => { + revealTimerRef.current = null; + setTouchRevealed(false); + }, TOUCH_SCROLL_REVEAL_MS); + return () => { + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + }; + }, [expanded, touchAutoHide]); + + const railVisible = !touchAutoHide || touchRevealed; + + const railLabel = isEn ? "Message navigation" : "楼层导航"; + + const pinnedTitle = isEn ? "Pinned" : "收藏"; + const pinLabel = isEn ? "Pin" : "收藏"; + const unpinLabel = isEn ? "Unpin" : "取消收藏"; + + const bookmarkedFloors = useMemo( + () => floors.filter((floor) => bookmarks.has(floor.messageId)), + [floors, bookmarks], + ); + + // 采样集合只由楼层与收藏决定(滚动不改变集合,整列不会随滚动抖动); + // 当前楼层未被采样时,高亮落到最近的已采样标记上。 + const collapsedMarkers = useMemo(() => { + const mustKeep = new Set(bookmarkedFloors.map((floor) => floor.rowKey)); + return sampleFloorEntries(floors, markerBudget, mustKeep); + }, [floors, bookmarkedFloors, markerBudget]); + const activeMarkerKey = useMemo( + () => resolveNearestSampledRowKey(floors, collapsedMarkers, activeRowKey), + [floors, collapsedMarkers, activeRowKey], + ); + + const cancelCollapse = useCallback(() => { + if (collapseTimerRef.current !== null) { + window.clearTimeout(collapseTimerRef.current); + collapseTimerRef.current = null; + } + }, []); + + const handleEnter = useCallback(() => { + cancelCollapse(); + setExpanded(true); + }, [cancelCollapse]); + + const handleLeave = useCallback(() => { + cancelCollapse(); + collapseTimerRef.current = window.setTimeout(() => { + collapseTimerRef.current = null; + setExpanded(false); + }, COLLAPSE_DELAY_MS); + }, [cancelCollapse]); + + // 触屏没有 mouseleave:面板展开期间点按导航栏以外任意位置立即收起。桌面端 + // 该监听与 mouseleave 收起并存,行为不冲突。 + useEffect(() => { + if (!expanded || !navEl) return; + const handlePointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && navEl.contains(event.target)) return; + cancelCollapse(); + setExpanded(false); + }; + document.addEventListener("pointerdown", handlePointerDown, true); + return () => document.removeEventListener("pointerdown", handlePointerDown, true); + }, [expanded, navEl, cancelCollapse]); + + const handleJump = useCallback( + (rowKey: string) => { + onJump(rowKey); + // 触屏跳转后面板不会因指针移出而收起,这里主动收;桌面保持展开便于连跳。 + if (isCoarsePointer) { + cancelCollapse(); + setExpanded(false); + } + }, + [onJump, isCoarsePointer, cancelCollapse], + ); + + // 悬停展开是纯鼠标增强;不挂 onFocus——聚焦即展开会把刚聚焦的短横线按钮 + // 卸载掉(焦点静默掉到 body)。键盘用户直接 Tab 到短横线回车跳转。 + const hoverHandlers = { + onMouseEnter: handleEnter, + onMouseLeave: handleLeave, + }; + + if (floors.length < 2) return null; + + const renderPanelRow = (floor: FloorEntry, isPinnedCopy = false) => { + const isActive = floor.rowKey === activeRowKey; + const isBookmarked = bookmarks.has(floor.messageId); + return ( +
+ + +
+ ); + }; + + return ( + + ); +} diff --git a/crates/agent-gateway/web/test/floor-nav.test.mjs b/crates/agent-gateway/web/test/floor-nav.test.mjs new file mode 100644 index 000000000..7a95a68fb --- /dev/null +++ b/crates/agent-gateway/web/test/floor-nav.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); + +const floorModel = loader.loadModule("src/lib/chat-floor-nav/floorModel.ts"); +const floorBookmarks = loader.loadModule("src/lib/chat-floor-nav/floorBookmarks.ts"); + +// WebUI 的楼层来源是 TranscriptRow(kind/key/text/messageRef 结构子集与桌面端 +// RenderTimelineItem 兼容,floorModel 因此可字节镜像)。 +function userRow(key, text, messageId) { + return { + key, + origin: "history", + kind: "user", + text, + attachments: [], + messageRef: messageId + ? { + segmentIndex: 0, + messageIndex: 0, + segmentId: "seg", + messageId, + role: "user", + contentHash: "h", + } + : undefined, + timestamp: 0, + }; +} + +test("buildFloorEntries keeps only user rows and builds previews", () => { + const rows = [ + { key: "c1", origin: "history", kind: "checkpoint" }, + userRow("u1", " 帮我看看\n这个 bug 在哪 ", "user-aaa"), + { key: "a1", origin: "history", kind: "assistant", rounds: [] }, + userRow("u2", "x".repeat(60), "user-bbb"), + userRow("u3", " ", undefined), + ]; + const floors = floorModel.buildFloorEntries(rows); + assert.equal(floors.length, 3); + assert.deepEqual( + floors.map((f) => f.rowKey), + ["u1", "u2", "u3"], + ); + assert.equal(floors[0].preview, "帮我看看 这个 bug 在哪"); + assert.equal(floors[0].messageId, "user-aaa"); + assert.ok(floors[1].preview.endsWith("…")); + assert.equal(floors[1].preview.length, 25); + assert.equal(floors[2].preview, "…"); + // 无 messageRef 时回退到行 key,收藏仍可用 + assert.equal(floors[2].messageId, "u3"); +}); + +test("sampleFloorEntries keeps bookmarked floors and stays continuous at the cap", () => { + const floors = Array.from( + { length: 100 }, + (_, i) => floorModel.buildFloorEntries([userRow(`u${i}`, `msg ${i}`, `user-${i}`)])[0], + ); + const mustKeep = new Set(["u37", "u73"]); + const sampled = floorModel.sampleFloorEntries(floors, 20, mustKeep); + assert.ok(sampled.length <= 20 + mustKeep.size); + assert.ok(sampled.some((f) => f.rowKey === "u37")); + assert.ok(sampled.some((f) => f.rowKey === "u73")); + assert.equal(sampled[0].rowKey, "u0"); + assert.equal(sampled[sampled.length - 1].rowKey, "u99"); + + // 越过上限时标记数连续过渡:25 层限 24 不应骤降到一半 + const floors25 = floors.slice(0, 25); + const sampled25 = floorModel.sampleFloorEntries(floors25, 24, new Set()); + assert.ok(sampled25.length >= 23, `expected >=23 markers, got ${sampled25.length}`); +}); + +test("resolveNearestSampledRowKey maps active floor to nearest marker", () => { + const floors = Array.from( + { length: 10 }, + (_, i) => floorModel.buildFloorEntries([userRow(`u${i}`, `msg ${i}`, `user-${i}`)])[0], + ); + const sampled = [floors[0], floors[5], floors[9]]; + assert.equal(floorModel.resolveNearestSampledRowKey(floors, sampled, "u5"), "u5"); + assert.equal(floorModel.resolveNearestSampledRowKey(floors, sampled, "u6"), "u5"); + assert.equal(floorModel.resolveNearestSampledRowKey(floors, sampled, "u8"), "u9"); + assert.equal(floorModel.resolveNearestSampledRowKey(floors, sampled, null), null); + assert.equal(floorModel.resolveNearestSampledRowKey(floors, sampled, "missing"), null); +}); + +test("buildFloorPreview truncates on code points without splitting surrogates", () => { + const emoji = "😀".repeat(30); + const preview = floorModel.buildFloorPreview(emoji); + assert.ok(preview.endsWith("…")); + const chars = Array.from(preview); + assert.equal(chars.length, 25); + for (const ch of chars.slice(0, -1)) { + assert.equal(ch, "😀", `expected intact emoji, got ${JSON.stringify(ch)}`); + } +}); + +test("floor bookmarks toggle and persist through localStorage", () => { + const store = new Map(); + globalThis.localStorage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }; + try { + floorBookmarks.resetFloorBookmarksCacheForTest(); + assert.equal(floorBookmarks.getFloorBookmarks("conv-1").size, 0); + + let notified = 0; + const unsubscribe = floorBookmarks.subscribeFloorBookmarks(() => { + notified += 1; + }); + + floorBookmarks.toggleFloorBookmark("conv-1", "user-aaa"); + assert.ok(floorBookmarks.getFloorBookmarks("conv-1").has("user-aaa")); + assert.equal(notified, 1); + + // 引用稳定:未写入时快照不变 + const snapshot = floorBookmarks.getFloorBookmarks("conv-1"); + assert.equal(floorBookmarks.getFloorBookmarks("conv-1"), snapshot); + + // 重读磁盘(模拟重启)后收藏仍在 + floorBookmarks.resetFloorBookmarksCacheForTest(); + assert.ok(floorBookmarks.getFloorBookmarks("conv-1").has("user-aaa")); + + floorBookmarks.toggleFloorBookmark("conv-1", "user-aaa"); + assert.equal(floorBookmarks.getFloorBookmarks("conv-1").size, 0); + unsubscribe(); + + // 损坏数据不抛错 + store.set("liveagent.floor-bookmarks.v1", "{not json"); + floorBookmarks.resetFloorBookmarksCacheForTest(); + assert.equal(floorBookmarks.getFloorBookmarks("conv-1").size, 0); + } finally { + delete globalThis.localStorage; + } +}); + +test("bookmark eviction trims memory and disk together", () => { + const store = new Map(); + globalThis.localStorage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }; + try { + floorBookmarks.resetFloorBookmarksCacheForTest(); + for (let i = 0; i < 205; i++) { + floorBookmarks.toggleFloorBookmark(`conv-${i}`, `user-${i}`); + } + // 内存立即淘汰最旧会话(与磁盘一致),最新会话保留 + assert.equal(floorBookmarks.getFloorBookmarks("conv-0").size, 0); + assert.equal(floorBookmarks.getFloorBookmarks("conv-204").size, 1); + // 重读磁盘后状态一致 + floorBookmarks.resetFloorBookmarksCacheForTest(); + assert.equal(floorBookmarks.getFloorBookmarks("conv-0").size, 0); + assert.equal(floorBookmarks.getFloorBookmarks("conv-204").size, 1); + const payload = JSON.parse(store.get("liveagent.floor-bookmarks.v1")); + assert.ok(Object.keys(payload.conversations).length <= 200); + } finally { + delete globalThis.localStorage; + } +}); diff --git a/crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts b/crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts index da96e0d24..e0e813d6d 100644 --- a/crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts +++ b/crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts @@ -1,8 +1,6 @@ -import type { RenderTimelineItem } from "../chat/conversation/conversationState"; - /** 楼层导航条目:一条用户发送的消息。 */ export type FloorEntry = { - /** 虚拟列表行 key(与 rowModel 的用户行 key 一致),用于跳转定位。 */ + /** 虚拟列表行 key(与行模型的用户行 key 一致),用于跳转定位。 */ rowKey: string; /** 稳定消息 id(持久化于 SQLite,重启不变),用于收藏。 */ messageId: string; @@ -10,6 +8,17 @@ export type FloorEntry = { preview: string; }; +/** + * 楼层来源行的最小结构:桌面端渲染时间线(RenderTimelineItem)与 WebUI 转写 + * 行(TranscriptRow)都满足此形状,本模块因此可在两端字节级镜像。 + */ +export type FloorSourceItem = { + kind: string; + key: string; + text?: string; + messageRef?: { messageId: string }; +}; + const PREVIEW_MAX_CHARS = 24; export function buildFloorPreview(text: string): string { @@ -23,17 +32,17 @@ export function buildFloorPreview(text: string): string { } /** - * 从渲染时间线派生楼层列表。只保留 kind === "user" 的条目——工具调用/返回 + * 从渲染行列表派生楼层列表。只保留 kind === "user" 的条目——工具调用/返回 * 折叠在 assistant 组内、系统提示词不在时间线上,因此天然只剩用户消息。 */ -export function buildFloorEntries(items: RenderTimelineItem[]): FloorEntry[] { +export function buildFloorEntries(items: readonly FloorSourceItem[]): FloorEntry[] { const entries: FloorEntry[] = []; for (const item of items) { if (item.kind !== "user") continue; entries.push({ rowKey: item.key, messageId: item.messageRef?.messageId ?? item.key, - preview: buildFloorPreview(item.text), + preview: buildFloorPreview(item.text ?? ""), }); } return entries; diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index 26e83e654..6209a9719 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -276,7 +276,8 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript conversationId={conversationId} floors={floors} activeRowKey={activeFloorKey} - bottomReservePx={transcriptBottomReservePx} + bottomOffset={`${Math.ceil(transcriptBottomReservePx) + 8}px`} + scrollViewport={scrollViewport} onJump={handleFloorJump} /> ) : null} diff --git a/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx b/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx index fb2d98028..e3f160a0b 100644 --- a/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx @@ -1,5 +1,6 @@ import { useCallback, + useEffect, useLayoutEffect, useMemo, useRef, @@ -24,10 +25,18 @@ import { cn } from "../../../lib/shared/utils"; /** 收起态短横线数量上限的绝对边界(实际数量随可用高度自适应)。 */ const MIN_COLLAPSED_MARKERS = 8; const MAX_COLLAPSED_MARKERS = 40; +/** + * 触屏端收起态上限单独收紧:手机视口高而窄,高度自适应会直接摸到桌面上限, + * 超长会话下整列标记撑满全屏高度、视觉噪音大;压成短列后配合 nav 的垂直 + * 居中布局只占屏幕中段一小截。楼层再多也只是采样更稀,首尾仍然保留。 + */ +const MAX_COLLAPSED_MARKERS_TOUCH = 12; /** 单根短横线(2.5px)+ 间距(7px)的占位高度。 */ const MARKER_SLOT_PX = 9.5; /** 鼠标移出后延迟收起,避免指针在轨道与面板间移动时闪烁。 */ const COLLAPSE_DELAY_MS = 160; +/** 触屏端:滚动停止后导航栏保持可见的时长,随后淡出避免遮挡内容。 */ +const TOUCH_SCROLL_REVEAL_MS = 1400; function useFloorBookmarks(conversationId: string): ReadonlySet { const getSnapshot = useCallback(() => getFloorBookmarks(conversationId), [conversationId]); @@ -38,11 +47,26 @@ export function FloorNavRail(props: { conversationId: string; floors: FloorEntry[]; activeRowKey: string | null; - /** 底部输入框悬浮区高度:导航栏整体避开,不遮挡输入框。 */ - bottomReservePx?: number; + /** + * 导航栏底缘的 CSS 偏移(避开底部输入框悬浮区)。桌面端传计算好的像素值 + * (如 "196px"),WebUI 传 CSS 变量表达式(如 "calc(var(--x) + 12px)")。 + */ + bottomOffset?: string; + /** + * 转写滚动视口。触屏端用于「滚动时显现、静止后淡出」——不传则触屏端也 + * 常显(桌面端 hover 交互不依赖此元素)。 + */ + scrollViewport?: HTMLElement | null; onJump: (rowKey: string) => void; }) { - const { conversationId, floors, activeRowKey, bottomReservePx = 0, onJump } = props; + const { + conversationId, + floors, + activeRowKey, + bottomOffset = "8px", + scrollViewport = null, + onJump, + } = props; const { locale } = useLocale(); const isEn = locale === "en-US"; const bookmarks = useFloorBookmarks(conversationId); @@ -54,20 +78,58 @@ export function FloorNavRail(props: { // 挂载 effect 会错过它——按元素身份重跑,观察器才始终挂在活着的节点上。 const [navEl, setNavEl] = useState(null); + // 触屏(无 hover)环境:展开/收起改由点按驱动,跳转后主动收起面板。 + const isCoarsePointer = useMemo( + () => + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(hover: none), (pointer: coarse)").matches, + [], + ); + + // 触屏端滚动显隐:平时整体隐藏不遮内容,滚动中显现、静止一段时间后淡出。 + // 面板展开期间不淡出(用户正在交互);隐藏态关闭指针事件,透传给转写区。 + const [touchRevealed, setTouchRevealed] = useState(false); + const revealTimerRef = useRef(null); + const expandedRef = useRef(false); + useEffect(() => { + if (!isCoarsePointer || !scrollViewport) return; + const handleScroll = () => { + setTouchRevealed(true); + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + } + revealTimerRef.current = window.setTimeout(() => { + revealTimerRef.current = null; + // 面板展开中不淡出;面板收起时(handleLeave/外点)会重新走到这里。 + if (!expandedRef.current) setTouchRevealed(false); + }, TOUCH_SCROLL_REVEAL_MS); + }; + scrollViewport.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + scrollViewport.removeEventListener("scroll", handleScroll); + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + }; + }, [isCoarsePointer, scrollViewport]); + // 收起态标记数随聊天区可用高度自适应:矮视口(小窗口/高输入框)少放几根, - // 保证最新楼层的标记不被裁掉。 - const [markerBudget, setMarkerBudget] = useState(MAX_COLLAPSED_MARKERS); + // 保证最新楼层的标记不被裁掉。触屏端上限另行收紧(见常量注释)。 + const maxMarkers = isCoarsePointer ? MAX_COLLAPSED_MARKERS_TOUCH : MAX_COLLAPSED_MARKERS; + const [markerBudget, setMarkerBudget] = useState(maxMarkers); useLayoutEffect(() => { if (!navEl || typeof ResizeObserver === "undefined") return; const update = () => { const budget = Math.floor((navEl.clientHeight - 24) / MARKER_SLOT_PX); - setMarkerBudget(Math.max(MIN_COLLAPSED_MARKERS, Math.min(MAX_COLLAPSED_MARKERS, budget))); + setMarkerBudget(Math.max(MIN_COLLAPSED_MARKERS, Math.min(maxMarkers, budget))); }; update(); const observer = new ResizeObserver(update); observer.observe(navEl); return () => observer.disconnect(); - }, [navEl]); + }, [navEl, maxMarkers]); // 展开时把当前楼层滚到面板中间,楼层很多时不必从头找。 useLayoutEffect(() => { @@ -77,7 +139,37 @@ export function FloorNavRail(props: { ?.scrollIntoView({ block: "center" }); }, [expanded]); + // 触屏自动隐藏仅在提供了滚动视口时启用。 + const touchAutoHide = isCoarsePointer && scrollViewport !== null; + + // 面板展开期间强制可见并挂起淡出计时;收起后重新计时淡出。 + useEffect(() => { + expandedRef.current = expanded; + if (!touchAutoHide) return; + if (expanded) { + setTouchRevealed(true); + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + return; + } + revealTimerRef.current = window.setTimeout(() => { + revealTimerRef.current = null; + setTouchRevealed(false); + }, TOUCH_SCROLL_REVEAL_MS); + return () => { + if (revealTimerRef.current !== null) { + window.clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + }; + }, [expanded, touchAutoHide]); + + const railVisible = !touchAutoHide || touchRevealed; + const railLabel = isEn ? "Message navigation" : "楼层导航"; + const pinnedTitle = isEn ? "Pinned" : "收藏"; const pinLabel = isEn ? "Pin" : "收藏"; const unpinLabel = isEn ? "Unpin" : "取消收藏"; @@ -118,6 +210,31 @@ export function FloorNavRail(props: { }, COLLAPSE_DELAY_MS); }, [cancelCollapse]); + // 触屏没有 mouseleave:面板展开期间点按导航栏以外任意位置立即收起。桌面端 + // 该监听与 mouseleave 收起并存,行为不冲突。 + useEffect(() => { + if (!expanded || !navEl) return; + const handlePointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && navEl.contains(event.target)) return; + cancelCollapse(); + setExpanded(false); + }; + document.addEventListener("pointerdown", handlePointerDown, true); + return () => document.removeEventListener("pointerdown", handlePointerDown, true); + }, [expanded, navEl, cancelCollapse]); + + const handleJump = useCallback( + (rowKey: string) => { + onJump(rowKey); + // 触屏跳转后面板不会因指针移出而收起,这里主动收;桌面保持展开便于连跳。 + if (isCoarsePointer) { + cancelCollapse(); + setExpanded(false); + } + }, + [onJump, isCoarsePointer, cancelCollapse], + ); + // 悬停展开是纯鼠标增强;不挂 onFocus——聚焦即展开会把刚聚焦的短横线按钮 // 卸载掉(焦点静默掉到 body)。键盘用户直接 Tab 到短横线回车跳转。 const hoverHandlers = { @@ -142,7 +259,7 @@ export function FloorNavRail(props: { >