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
18 changes: 17 additions & 1 deletion crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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<ScrollFollowHandle>(
() => ({
stickToBottom,
jumpToBottom,
breakFollow,
isFollowing: () => stateRef.current.following,
}),
[jumpToBottom, stickToBottom],
[breakFollow, jumpToBottom, stickToBottom],
);

return { handle, following };
Expand Down
108 changes: 108 additions & 0 deletions crates/agent-gui/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;
}
90 changes: 90 additions & 0 deletions crates/agent-gui/src/lib/chat-floor-nav/floorModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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<string>,
): FloorEntry[] {
if (maxMarkers <= 0) return [];
if (floors.length <= maxMarkers) return floors;
const picked = new Set<number>();
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;
}
18 changes: 17 additions & 1 deletion crates/agent-gui/src/lib/chat-scroll/useScrollFollow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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<ScrollFollowHandle>(
() => ({
stickToBottom,
jumpToBottom,
breakFollow,
isFollowing: () => stateRef.current.following,
}),
[jumpToBottom, stickToBottom],
[breakFollow, jumpToBottom, stickToBottom],
);

return { handle, following };
Expand Down
34 changes: 33 additions & 1 deletion crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string | null>(null);
const transcriptNavRef = useRef<TranscriptNavHandle | null>(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({
Expand Down Expand Up @@ -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}
Expand All @@ -248,6 +271,15 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript
<div style={{ height: transcriptBottomReservePx }} />
</div>
</ScrollArea>
{!showNoModelsState && !showStartChatState && !isTranscriptSettling ? (
<FloorNavRail
conversationId={conversationId}
floors={floors}
activeRowKey={activeFloorKey}
bottomReservePx={transcriptBottomReservePx}
onJump={handleFloorJump}
/>
) : null}
{!following ? (
<button
type="button"
Expand Down
Loading
Loading