From 5aea563f76a3ed7a67092646a2c54f4932c8c5ad Mon Sep 17 00:00:00 2001 From: 24baigei Date: Sun, 19 Jul 2026 11:59:43 +0800 Subject: [PATCH 1/4] feat(chat): add floor navigation rail with bookmarks to the transcript Add a DeepSeek-style floor navigation rail on the right edge of the chat transcript for quickly locating user messages in long conversations. - Collapsed state shows a bare column of dashes (height-adaptive count via ResizeObserver, evenly sampled with bookmarked floors always kept); the current floor maps to the nearest sampled marker so the column never jitters while scrolling. Hovering expands a glass panel listing each user message's first characters; clicking jumps to that message. - Only user-sent messages count as floors: floors derive from timeline items with kind === "user", so tool calls/results (folded into assistant groups) and system prompts are excluded by construction. - Jumping drives virtualizer.scrollToIndex with a multi-frame settle loop that converges under dynamic row measurement and is cancelled by user wheel/touch/key input, a superseding jump, or unmount. A new ScrollFollowHandle.breakFollow() (historyKey semantics with measured hasOverflow) detaches bottom-follow first so the jump is not re-pinned; the web mirror of useScrollFollow.ts is updated byte-identically. - The active floor tracks the viewport top edge (+8px) to match the jump alignment, with a bottom special case (scrollHeight coordinates) so the last floor wins when pinned at the bottom. - Floors can be pinned: bookmarks persist per conversation by stable message id in a versioned localStorage blob (LRU-capped at 200 conversations, memory and disk trimmed together) and render as an amber pinned section at the top of the panel. - The rail avoids the composer overlay (bottomReservePx), hides for <2 floors, and keeps keyboard access via focusable dash buttons. Includes unit tests for floor derivation, preview truncation (code-point safe), sampling continuity, nearest-marker resolution, and bookmark persistence/eviction. Co-Authored-By: Claude Fable 5 --- .../src/lib/chat-scroll/useScrollFollow.ts | 18 +- .../src/lib/chat-floor-nav/floorBookmarks.ts | 108 +++++++++ .../src/lib/chat-floor-nav/floorModel.ts | 92 ++++++++ .../src/lib/chat-scroll/useScrollFollow.ts | 18 +- .../pages/chat/transcript/ChatTranscript.tsx | 34 ++- .../pages/chat/transcript/FloorNavRail.tsx | 223 ++++++++++++++++++ .../pages/chat/transcript/TranscriptList.tsx | 133 +++++++++++ crates/agent-gui/test/chat/floor-nav.test.mjs | 153 ++++++++++++ 8 files changed, 776 insertions(+), 3 deletions(-) create mode 100644 crates/agent-gui/src/lib/chat-floor-nav/floorBookmarks.ts create mode 100644 crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts create mode 100644 crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx create mode 100644 crates/agent-gui/test/chat/floor-nav.test.mjs diff --git a/crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts b/crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts index 13a3efd19..b2cdb2501 100644 --- a/crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts +++ b/crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts @@ -58,6 +58,11 @@ export type ScrollFollowHandle = { // (the jump button); programmatic pins (conversation switch, run start) // stay instant via stickToBottom. jumpToBottom: () => void; + // Detach follow mode for a programmatic jump into history (floor + // navigation, search results): reuses the historyKey semantics so the + // engine treats it exactly like a user-initiated jump away from the + // bottom, instead of re-pinning over the new scroll position. + breakFollow: () => void; isFollowing: () => boolean; }; @@ -379,13 +384,24 @@ export function useScrollFollow(args: UseScrollFollowArgs): { viewport, ]); + const breakFollow = useCallback(() => { + cancelJumpAnimation(); + // 与键盘 historyKey 路径同构:溢出与否取实测值(无溢出时 reducer 不解除 + // 跟随,避免「视觉在底部却被搁浅为 off」),时间基与其余事件一致用 Date.now()。 + const el = boundViewportRef.current; + const hasOverflow = + el !== null && el.scrollHeight - el.clientHeight > SCROLLABLE_OVERFLOW_MIN_PX; + dispatch({ type: "historyKey", hasOverflow, now: Date.now() }); + }, [cancelJumpAnimation, dispatch]); + const handle = useMemo( () => ({ stickToBottom, jumpToBottom, + breakFollow, isFollowing: () => stateRef.current.following, }), - [jumpToBottom, stickToBottom], + [breakFollow, jumpToBottom, stickToBottom], ); return { handle, following }; diff --git a/crates/agent-gui/src/lib/chat-floor-nav/floorBookmarks.ts b/crates/agent-gui/src/lib/chat-floor-nav/floorBookmarks.ts new file mode 100644 index 000000000..81b87d372 --- /dev/null +++ b/crates/agent-gui/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-gui/src/lib/chat-floor-nav/floorModel.ts b/crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts new file mode 100644 index 000000000..be0290b56 --- /dev/null +++ b/crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts @@ -0,0 +1,92 @@ +import type { RenderTimelineItem } from "../chat/conversation/conversationState"; + +/** 楼层导航条目:一条用户发送的消息。 */ +export type FloorEntry = { + /** 虚拟列表行 key(与 rowModel 的用户行 key 一致),用于跳转定位。 */ + rowKey: string; + /** 稳定消息 id(持久化于 SQLite,重启不变),用于收藏。 */ + messageId: string; + /** 消息开头若干字符,空白折叠后截断。 */ + preview: 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: RenderTimelineItem[]): 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-gui/src/lib/chat-scroll/useScrollFollow.ts b/crates/agent-gui/src/lib/chat-scroll/useScrollFollow.ts index 13a3efd19..b2cdb2501 100644 --- a/crates/agent-gui/src/lib/chat-scroll/useScrollFollow.ts +++ b/crates/agent-gui/src/lib/chat-scroll/useScrollFollow.ts @@ -58,6 +58,11 @@ export type ScrollFollowHandle = { // (the jump button); programmatic pins (conversation switch, run start) // stay instant via stickToBottom. jumpToBottom: () => void; + // Detach follow mode for a programmatic jump into history (floor + // navigation, search results): reuses the historyKey semantics so the + // engine treats it exactly like a user-initiated jump away from the + // bottom, instead of re-pinning over the new scroll position. + breakFollow: () => void; isFollowing: () => boolean; }; @@ -379,13 +384,24 @@ export function useScrollFollow(args: UseScrollFollowArgs): { viewport, ]); + const breakFollow = useCallback(() => { + cancelJumpAnimation(); + // 与键盘 historyKey 路径同构:溢出与否取实测值(无溢出时 reducer 不解除 + // 跟随,避免「视觉在底部却被搁浅为 off」),时间基与其余事件一致用 Date.now()。 + const el = boundViewportRef.current; + const hasOverflow = + el !== null && el.scrollHeight - el.clientHeight > SCROLLABLE_OVERFLOW_MIN_PX; + dispatch({ type: "historyKey", hasOverflow, now: Date.now() }); + }, [cancelJumpAnimation, dispatch]); + const handle = useMemo( () => ({ stickToBottom, jumpToBottom, + breakFollow, isFollowing: () => stateRef.current.following, }), - [jumpToBottom, stickToBottom], + [breakFollow, jumpToBottom, stickToBottom], ); return { handle, following }; diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index 6a02bf592..26e83e654 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, } from "react"; @@ -12,13 +13,15 @@ import { createPortal } from "react-dom"; import { ChevronDown, Copy } from "../../../components/icons"; import { ScrollArea } from "../../../components/ui/scroll-area"; import { useLocale } from "../../../i18n"; +import { buildFloorEntries } from "../../../lib/chat-floor-nav/floorModel"; import { BOTTOM_REATTACH_ZONE_PX } from "../../../lib/chat-scroll/scrollFollowCore"; import { useScrollFollow } from "../../../lib/chat-scroll/useScrollFollow"; import { useMenuExitPresence } from "../../../lib/shared/menuMotion"; import { cn } from "../../../lib/shared/utils"; import { ChatEmptyState } from "./ChatEmptyState"; +import { FloorNavRail } from "./FloorNavRail"; import { RowInteractionProvider, useRowInteractionStore } from "./rowInteraction"; -import { TranscriptList } from "./TranscriptList"; +import { TranscriptList, type TranscriptNavHandle } from "./TranscriptList"; import { HistorySwitchLoadingOverlay } from "./TranscriptLoadingStates"; import type { ChatTranscriptProps } from "./transcriptTypes"; import { @@ -84,6 +87,24 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript config: { reattachZonePx: BOTTOM_REATTACH_ZONE_PX }, }); + // 楼层导航:从时间线派生用户消息楼层;当前楼层由 TranscriptList 上报。 + // 不在此处按 conversationId 重置——TranscriptList 按会话重挂载后其挂载 + // effect 会先于本组件的 effect 执行并上报新会话锚点,这里再置 null 会把 + // 刚上报的值清掉且被子组件的去重永久抑制。行 key 含 segmentId,跨会话 + // 不会误匹配,等待子组件上报即可。 + const floors = useMemo(() => buildFloorEntries(historyItems), [historyItems]); + const [activeFloorKey, setActiveFloorKey] = useState(null); + const transcriptNavRef = useRef(null); + const handleFloorJump = useCallback( + (rowKey: string) => { + // 粘底跟随激活时程序化滚动会被立即拽回底部——先按「跳入历史」语义解除 + // 跟随,再执行跳转。 + scrollFollowHandle.breakFollow(); + transcriptNavRef.current?.scrollToRowKey(rowKey); + }, + [scrollFollowHandle], + ); + // Run-scoped state reaches row action bars through this store instead of // row props, so settled rows stay memo-stable across run start/settle. const rowInteractionStore = useRowInteractionStore({ @@ -238,6 +259,8 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript usageContextWindow={usageContextWindow} workspaceRoot={workspaceRoot} gitClient={gitClient} + navRef={transcriptNavRef} + onAnchorUserRowChange={setActiveFloorKey} onResendFromEdit={onResendFromEdit} onBranchConversation={onBranchConversation} onFirstLayoutSettled={handleFirstLayoutSettled} @@ -248,6 +271,15 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript
+ {!showNoModelsState && !showStartChatState && !isTranscriptSettling ? ( + + ) : null} {!following ? ( + + + ); + }; + + return ( + + ); +} diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx index b23b1abb1..378565324 100644 --- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx @@ -1,5 +1,6 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { + type MutableRefObject, memo, type ReactNode, useCallback, @@ -88,6 +89,11 @@ const SummaryCard = memo(function SummaryCard(props: { item: RenderSummaryCard } ); }); +export type TranscriptNavHandle = { + /** 按行 key 跳转到对应消息(动态行高下会连帧重对准确保落位)。 */ + scrollToRowKey: (rowKey: string) => void; +}; + export type TranscriptListProps = { conversationId: string; historyItems: RenderTimelineItem[]; @@ -103,6 +109,10 @@ export type TranscriptListProps = { usageContextWindow?: number; workspaceRoot?: string; gitClient?: GitClient | null; + // 楼层导航:跳转句柄挂载点(与 followRef 同一模式),以及「视口顶部 + // 当前处于哪条用户消息行」变化时的上报回调。 + navRef?: MutableRefObject; + onAnchorUserRowChange?: (rowKey: string | null) => void; onResendFromEdit: ( messageRef: HistoryMessageRef, text: string, @@ -135,6 +145,8 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList usageContextWindow, workspaceRoot, gitClient, + navRef, + onAnchorUserRowChange, onResendFromEdit, onBranchConversation, onFirstLayoutSettled, @@ -263,6 +275,127 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList isFollowing: () => isViewportFollowing?.() ?? false, }); + // 楼层导航跳转句柄:按行 key 定位 index 后 scrollToIndex。沿途行首次真实 + // 测量会不断修正总高度,连续若干帧重新对准,让滚动收敛在目标行顶部 + // (对准同一 index 是收敛操作,不会震荡)。收敛期间用户的滚轮/触摸/按键 + // 立即取消收敛;新跳转替换旧收敛;卸载时一并清理。 + const rowsRef = useRef(rows); + rowsRef.current = rows; + const cancelJumpSettleRef = useRef<() => void>(() => {}); + useLayoutEffect(() => { + if (!navRef) return; + const handle: TranscriptNavHandle = { + scrollToRowKey: (rowKey) => { + cancelJumpSettleRef.current(); + const alignToRow = () => { + const index = rowsRef.current.findIndex((row) => row.key === rowKey); + if (index < 0) return false; + virtualizer.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, virtualizer, 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 rowList = rowsRef.current; + let anchorKey: string | null = null; + if (rowList.length > 0) { + const scrollTop = scrollViewport.scrollTop; + const viewportHeight = scrollViewport.clientHeight; + const nearBottom = scrollTop + viewportHeight >= scrollViewport.scrollHeight - 32; + let anchorIndex = -1; + if (nearBottom) { + anchorIndex = rowList.length - 1; + } else { + const anchorLine = scrollTop + 8; + const items = virtualizer.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, rowList.length - 1); i >= 0; i--) { + const row = rowList[i]; + if (row?.kind === "user") { + anchorKey = 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]); + + // 行集合变化(消息追加、流式落定)后兜底重算一次;依赖 rows 而不是每次 + // 渲染都跑,避免「上报 → 父级重渲染 → 再上报」的空转循环。 + useEffect(() => { + rowsRef.current = rows; + reportAnchorRef.current(); + }, [rows]); + // First paint of a conversation lands at the bottom before the user sees // anything: scrollToEnd re-targets as dynamic measurements land, replacing // the old estimated-pin → measure → re-pin dance. The component remounts diff --git a/crates/agent-gui/test/chat/floor-nav.test.mjs b/crates/agent-gui/test/chat/floor-nav.test.mjs new file mode 100644 index 000000000..7cc1a8db3 --- /dev/null +++ b/crates/agent-gui/test/chat/floor-nav.test.mjs @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const floorModel = loader.loadModule("src/lib/chat-floor-nav/floorModel.ts"); +const floorBookmarks = loader.loadModule("src/lib/chat-floor-nav/floorBookmarks.ts"); + +function userItem(key, text, messageId) { + return { + kind: "user", + key, + segmentIndex: 0, + messageRef: messageId + ? { segmentIndex: 0, messageIndex: 0, segmentId: "seg", messageId, role: "user", contentHash: "h" } + : undefined, + text, + attachments: [], + timestamp: 0, + isFromCompactedSegment: false, + }; +} + +test("buildFloorEntries keeps only user items and builds previews", () => { + const items = [ + { kind: "summary", key: "s1" }, + userItem("u1", " 帮我看看\n这个 bug 在哪 ", "user-aaa"), + { kind: "assistant", key: "a1", rounds: [] }, + userItem("u2", "x".repeat(60), "user-bbb"), + userItem("u3", " ", undefined), + ]; + const floors = floorModel.buildFloorEntries(items); + 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([userItem(`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([userItem(`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; + } +}); From 63e6a0aaa565c182dac5be38327f3502c59ed33d Mon Sep 17 00:00:00 2001 From: 24baigei Date: Sun, 19 Jul 2026 12:37:28 +0800 Subject: [PATCH 2/4] fix(chat): drop unused a11y suppressions in FloorNavRail The hover-only wrappers no longer carry focus handlers, so the noStaticElementInteractions suppressions stopped matching and biome reports them as unused (error severity) in CI. Co-Authored-By: Claude Fable 5 --- crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx b/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx index 7975afc9c..7522deaf5 100644 --- a/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx @@ -170,7 +170,6 @@ export function FloorNavRail(props: { style={{ bottom: Math.ceil(bottomReservePx) + 8 }} > {expanded ? ( - // biome-ignore lint/a11y/noStaticElementInteractions: 悬停仅控制面板展开/收起,交互本体是内部按钮。
) : ( - // biome-ignore lint/a11y/noStaticElementInteractions: 悬停仅控制面板展开/收起,短横线按钮本身可聚焦、可回车跳转。
Date: Sun, 19 Jul 2026 12:42:01 +0800 Subject: [PATCH 3/4] style(chat): apply biome formatting to floor-nav files Co-Authored-By: Claude Fable 5 --- .../agent-gui/src/lib/chat-floor-nav/floorModel.ts | 4 +--- .../src/pages/chat/transcript/FloorNavRail.tsx | 13 +++++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) 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 be0290b56..da96e0d24 100644 --- a/crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts +++ b/crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts @@ -59,9 +59,7 @@ export function sampleFloorEntries( 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), - ); + return floors.filter((floor, index) => picked.has(index) || mustKeepRowKeys.has(floor.rowKey)); } /** diff --git a/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx b/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx index 7522deaf5..590247ff8 100644 --- a/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/FloorNavRail.tsx @@ -1,4 +1,11 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { Pin } from "../../../components/icons"; import { useLocale } from "../../../i18n"; @@ -52,9 +59,7 @@ export function FloorNavRail(props: { if (!nav || typeof ResizeObserver === "undefined") return; const update = () => { const budget = Math.floor((nav.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(MAX_COLLAPSED_MARKERS, budget))); }; update(); const observer = new ResizeObserver(update); From 80717645530fa4a5a57a5169cd3e5af04820f47a Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sun, 19 Jul 2026 16:45:42 +0800 Subject: [PATCH 4/4] fix(chat): re-arm floor rail ResizeObserver when the nav element remounts The marker-budget effect ran once on mount with an empty dependency array, but the rail renders null while floors.length < 2, so when a conversation grows past the threshold the