Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -309,6 +311,17 @@ export default function GatewayApp() {
listenerRoot: transcriptScrollAreaRoot,
trackKeys: true,
});
// 楼层导航:当前楼层由转写区上报,跳转经 navRef 直达虚拟列表;粘底跟随
// 激活时程序化滚动会被立即拽回底部——跳转前先按「跳入历史」语义解除跟随。
const transcriptNavRef = useRef<GatewayTranscriptNavHandle | null>(null);
const [activeFloorKey, setActiveFloorKey] = useState<string | null>(null);
const handleFloorJump = useCallback(
(rowKey: string) => {
transcriptFollow.breakFollow();
transcriptNavRef.current?.scrollToRowKey(rowKey);
},
[transcriptFollow],
);
const composerRef = useRef<MentionComposerHandle | null>(null);
const composerDraftCacheRef = useRef<Map<string, MentionComposerDraft>>(new Map());
const composerDraftOwnerRef = useRef("");
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand All @@ -4210,6 +4226,16 @@ export default function GatewayApp() {
suggestionsDisabled={isSuggestionTyping}
/>
</ScrollArea>
{displayedTranscriptRowCount > 0 && !conversationOpenState.showOverlay ? (
<FloorNavRail
conversationId={displayedConversationId}
floors={transcriptFloors}
activeRowKey={activeFloorKey}
bottomOffset="calc(var(--gateway-chat-composer-overlay-height, 176px) + 12px)"
scrollViewport={transcriptViewport}
onJump={handleFloorJump}
/>
) : null}
{conversationOpenState.showOverlay ? (
<HistorySwitchLoadingOverlay locale={settings.locale} />
) : null}
Expand Down
140 changes: 140 additions & 0 deletions crates/agent-gateway/web/src/components/GatewayTranscript.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import {
type Dispatch,
type MutableRefObject,
memo,
type SetStateAction,
useCallback,
Expand Down Expand Up @@ -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<GatewayTranscriptNavHandle | null>;
// 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;
Expand Down Expand Up @@ -125,6 +130,13 @@ function rowRenderMode(row: Extract<TranscriptRow, { kind: "assistant" }>) {
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;
Expand Down Expand Up @@ -1150,6 +1162,8 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
activeTurnKey?: string | null;
scrollViewport: HTMLDivElement | null;
isViewportFollowing?: () => boolean;
navRef?: MutableRefObject<GatewayTranscriptNavHandle | null>;
onAnchorUserRowChange?: (rowKey: string | null) => void;
hasMoreHistory?: boolean;
isLoadingMoreHistory?: boolean;
onLoadFullHistory?: () => void;
Expand Down Expand Up @@ -1179,6 +1193,8 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
activeTurnKey,
scrollViewport,
isViewportFollowing,
navRef,
onAnchorUserRowChange,
hasMoreHistory,
isLoadingMoreHistory,
onLoadFullHistory,
Expand Down Expand Up @@ -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<string | null>(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
Expand Down Expand Up @@ -1569,6 +1705,8 @@ export function GatewayTranscript({
liveStartIndex = -1,
activeTurnKey = null,
isViewportFollowing,
navRef,
onAnchorUserRowChange,
error,
toolStatus,
toolStatusIsCompaction = false,
Expand Down Expand Up @@ -1652,6 +1790,8 @@ export function GatewayTranscript({
activeTurnKey={activeTurnKey}
scrollViewport={transcriptScrollViewport}
isViewportFollowing={isViewportFollowing}
navRef={navRef}
onAnchorUserRowChange={onAnchorUserRowChange}
hasMoreHistory={hasMoreHistory}
isLoadingMoreHistory={isLoadingMoreHistory}
onLoadFullHistory={onLoadFullHistory}
Expand Down
108 changes: 108 additions & 0 deletions crates/agent-gateway/web/src/lib/chat-floor-nav/floorBookmarks.ts
Original file line number Diff line number Diff line change
@@ -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<string> = new Set();

let cache: Map<string, ReadonlySet<string>> | null = null;
const listeners = new Set<() => void>();

function readStoredConversations(): Record<string, string[]> {
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<string, string[]> = {};
for (const [conversationId, ids] of Object.entries(conversations as Record<string, unknown>)) {
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<string, ReadonlySet<string>> {
if (!cache) {
cache = new Map(
Object.entries(readStoredConversations()).map(([conversationId, ids]) => [
conversationId,
new Set(ids) as ReadonlySet<string>,
]),
);
}
return cache;
}

function persist(map: Map<string, ReadonlySet<string>>) {
// 容量裁剪直接作用在内存 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<string> {
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;
}
Loading
Loading